forked from SublimeHaskell/SublimeHaskell
-
Notifications
You must be signed in to change notification settings - Fork 0
/
autobuild.py
200 lines (176 loc) · 7.33 KB
/
autobuild.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
import fnmatch
import functools
import os
import re
import sublime
import sublime_plugin
import subprocess
from threading import Thread
import time
from sublime_haskell_common import get_cabal_project_dir_of_view, call_and_wait, log, are_paths_equal
ERROR_PANEL_NAME = 'haskell_error_checker'
# This regex matches an unindented line, followed by zero or more
# indented, non-empty lines.
# The first line is divided into a filename, a line number, and a column.
error_output_regex = re.compile(
r'^(\S*):(\d+):(\d+):(.*$(?:\n^[ \t].*$)*)',
re.MULTILINE)
# Extract the filename, line, column, and description from an error message:
result_file_regex = r'^(\S*?): line (\d+), column (\d+):$'
class SublimeHaskellAutobuild(sublime_plugin.EventListener):
def on_post_save(self, view):
# If the edited file was Haskell code within a cabal project, try to
# compile it.
cabal_project_dir = get_cabal_project_dir_of_view(view)
if cabal_project_dir is not None:
# On another thread, wait for the build to finish.
sublime.status_message('Rebuilding Haskell...')
thread = Thread(
target=wait_for_build_to_complete,
args=(view, cabal_project_dir))
thread.start()
class ErrorMessage(object):
"Describe an error or warning message produced by GHC."
def __init__(self, filename, line, column, message):
self.filename = filename
self.line = int(line)
self.column = int(column)
self.message = message
self.is_warning = 'warning' in message.lower()
def __unicode__(self):
# must match result_file_regex
return u'{0}: line {1}, column {2}:\n {3}'.format(
self.filename,
self.line,
self.column,
self.message)
def find_region_in_view(self, view):
"Return the Region referred to by this error message."
# Convert line and column count to zero-based indices:
point = view.text_point(self.line - 1, self.column - 1)
# Return the whole line:
region = view.line(point)
region = trim_region(view, region)
return region
def wait_for_build_to_complete(view, cabal_project_dir):
"""Start 'cabal build', wait for it to complete, then parse and diplay
the resulting errors."""
# First hide error panel to show that something is going on
sublime.set_timeout(lambda: hide_output(view), 0)
exit_code, stdout, stderr = call_and_wait(
['cabal', 'build'],
cwd=cabal_project_dir)
# stderr/stdout can contain unicode characters
stdout = stderr.decode('utf-8')
stderr = stderr.decode('utf-8')
success = exit_code == 0
# The process has terminated; parse and display the output:
parsed_messages = parse_error_messages(cabal_project_dir, stderr)
if parsed_messages:
error_messages = u'\n'.join(unicode(x) for x in parsed_messages)
else:
error_messages = stderr
success_message = 'SUCCEEDED' if success else 'FAILED'
output = u'{0}\n\nBuild {1}'.format(error_messages, success_message)
# Use set_timeout() so that the call occurs on the main Sublime thread:
callback = functools.partial(mark_errors_in_views, parsed_messages)
sublime.set_timeout(callback, 0)
# TODO make this an option
if success:
sublime.status_message("Rebuilding Haskell successful")
else:
callback = functools.partial(write_output, view, output, cabal_project_dir)
sublime.set_timeout(callback, 0)
def mark_errors_in_views(errors):
"Mark the regions in open views where errors were found."
begin_time = time.clock()
# Mark each diagnostic in each open view in all windows:
for w in sublime.windows():
for v in w.views():
view_filename = v.file_name()
# Unsaved files have no file name
if view_filename is None:
continue
errors_in_view = filter(
lambda x: are_paths_equal(view_filename, x.filename),
errors)
mark_errors_in_this_view(errors_in_view, v)
end_time = time.clock()
log('total time to mark {0} diagnostics: {1} seconds'.format(
len(errors), end_time - begin_time))
def mark_errors_in_this_view(errors, view):
WARNING_REGION_KEY = 'subhs-warnings'
ERROR_REGION_KEY = 'subhs-errors'
# Clear old regions:
view.erase_regions(WARNING_REGION_KEY)
view.erase_regions(ERROR_REGION_KEY)
# Add all error and warning regions in this view.
error_regions = []
warning_regions = []
for e in errors:
region = e.find_region_in_view(view)
if (e.is_warning):
warning_regions.append(region)
else:
error_regions.append(region)
# Mark warnings:
view.add_regions(
WARNING_REGION_KEY,
warning_regions,
'invalid.warning',
'grey_x',
sublime.DRAW_OUTLINED)
# Mark errors:
view.add_regions(
ERROR_REGION_KEY,
error_regions,
'invalid',
'grey_x',
sublime.DRAW_OUTLINED)
def write_output(view, text, cabal_project_dir):
"Write text to Sublime's output panel."
output_view = view.window().get_output_panel(ERROR_PANEL_NAME)
output_view.set_read_only(False)
# Configure Sublime's error message parsing:
output_view.settings().set("result_file_regex", result_file_regex)
output_view.settings().set("result_base_dir", cabal_project_dir)
# Write to the output buffer:
edit = output_view.begin_edit()
output_view.insert(edit, 0, text)
output_view.end_edit(edit)
# Set the selection to the beginning of the view so that "next result" works:
output_view.sel().clear()
output_view.sel().add(sublime.Region(0))
output_view.set_read_only(True)
# Show the results panel:
view.window().run_command('show_panel', {'panel': 'output.' + ERROR_PANEL_NAME})
def hide_output(view):
view.window().run_command('hide_panel', {'panel': 'output.' + ERROR_PANEL_NAME})
def parse_error_messages(base_dir, text):
"Parse text into a list of ErrorMessage objects."
matches = error_output_regex.finditer(text)
def to_error(m):
filename, line, column, messy_details = m.groups()
return ErrorMessage(
# Record the absolute, normalized path.
os.path.normpath(os.path.join(base_dir, filename)),
line,
column,
messy_details.strip())
return map(to_error, matches)
def trim_region(view, region):
"Return the specified Region, but without leading or trailing whitespace."
text = view.substr(region)
# Regions may be selected backwards, so b could be less than a.
a = min(region.a, region.b)
b = max(region.a, region.b)
# Figure out how much to move the endpoints to lose the space.
# If the region is entirely whitespace, give up and return it unchanged.
if text.isspace():
return region
else:
text_trimmed_on_left = text.lstrip()
text_trimmed = text_trimmed_on_left.rstrip()
a += len(text) - len(text_trimmed_on_left)
b -= len(text_trimmed_on_left) - len(text_trimmed)
return sublime.Region(a, b)