|
1
|
|
|
import os |
|
2
|
|
|
import re |
|
3
|
|
|
import shutil |
|
4
|
|
|
from subprocess import check_call, CalledProcessError, DEVNULL |
|
5
|
|
|
import tempfile |
|
6
|
|
|
|
|
7
|
|
|
from coalib.bears.Bear import Bear |
|
8
|
|
|
from coalib.misc.Decorators import enforce_signature |
|
9
|
|
|
from coalib.misc.Shell import escape_path_argument, run_shell_command |
|
10
|
|
|
from coalib.results.Diff import Diff |
|
11
|
|
|
from coalib.results.Result import Result |
|
12
|
|
|
from coalib.results.RESULT_SEVERITY import RESULT_SEVERITY |
|
13
|
|
|
|
|
14
|
|
|
|
|
15
|
|
|
class Lint(Bear): |
|
16
|
|
|
""" |
|
17
|
|
|
Deals with the creation of linting bears. |
|
18
|
|
|
|
|
19
|
|
|
For the tutorial see: |
|
20
|
|
|
http://coala.readthedocs.org/en/latest/Users/Tutorials/Linter_Bears.html |
|
21
|
|
|
|
|
22
|
|
|
:param executable: The executable to run the linter. |
|
23
|
|
|
:param prerequisite_command: The command to run as a prerequisite |
|
24
|
|
|
and is of type ``list``. |
|
25
|
|
|
:param prerequisites_fail_msg: The message to be displayed if the |
|
26
|
|
|
prerequisite fails. |
|
27
|
|
|
:param arguments: The arguments to supply to the linter, |
|
28
|
|
|
such that the file name to be analyzed |
|
29
|
|
|
can be appended to the end. Note that |
|
30
|
|
|
we use ``.format()`` on the arguments - |
|
31
|
|
|
so, ``{abc}`` needs to be given as |
|
32
|
|
|
``{{abc}}``. Currently, the following |
|
33
|
|
|
will be replaced: |
|
34
|
|
|
|
|
35
|
|
|
- ``{filename}`` - The filename passed |
|
36
|
|
|
to ``lint()`` |
|
37
|
|
|
- ``{config_file}`` - The config file |
|
38
|
|
|
created using ``config_file()`` |
|
39
|
|
|
|
|
40
|
|
|
:param output_regex: The regex which will match the output of the linter |
|
41
|
|
|
to get results. This is not used if |
|
42
|
|
|
``gives_corrected`` is set. This regex should give |
|
43
|
|
|
out the following variables: |
|
44
|
|
|
|
|
45
|
|
|
- line - The line where the issue starts. |
|
46
|
|
|
- column - The column where the issue starts. |
|
47
|
|
|
- end_line - The line where the issue ends. |
|
48
|
|
|
- end_column - The column where the issue ends. |
|
49
|
|
|
- severity - The severity of the issue. |
|
50
|
|
|
- message - The message of the result. |
|
51
|
|
|
- origin - The origin of the issue. |
|
52
|
|
|
|
|
53
|
|
|
:param diff_severity: The severity to use for all results if |
|
54
|
|
|
``gives_corrected`` is set. |
|
55
|
|
|
:param diff_message: The message to use for all results if |
|
56
|
|
|
``gives_corrected`` is set. |
|
57
|
|
|
:param use_stderr: Uses stderr as the output stream is it's True. |
|
58
|
|
|
:param use_stdin: Sends file as stdin instead of giving the file name. |
|
59
|
|
|
:param gives_corrected: True if the executable gives the corrected file |
|
60
|
|
|
or just the issues. |
|
61
|
|
|
:param severity_map: A dict where the keys are the possible severity |
|
62
|
|
|
values the Linter gives out and the values are the |
|
63
|
|
|
severity of the coala Result to set it to. If it is |
|
64
|
|
|
not a dict, it is ignored. |
|
65
|
|
|
""" |
|
66
|
|
|
executable = None |
|
67
|
|
|
prerequisite_command = None |
|
68
|
|
|
prerequisite_fail_msg = 'Unknown failure.' |
|
69
|
|
|
arguments = "" |
|
70
|
|
|
output_regex = re.compile(r'(?P<line>\d+)\.(?P<column>\d+)\|' |
|
71
|
|
|
r'(?P<severity>\d+): (?P<message>.*)') |
|
72
|
|
|
diff_message = 'No result message was set' |
|
73
|
|
|
diff_severity = RESULT_SEVERITY.NORMAL |
|
74
|
|
|
use_stderr = False |
|
75
|
|
|
use_stdin = False |
|
76
|
|
|
gives_corrected = False |
|
77
|
|
|
severity_map = None |
|
78
|
|
|
|
|
79
|
|
|
def lint(self, filename=None, file=None): |
|
80
|
|
|
""" |
|
81
|
|
|
Takes a file and lints it using the linter variables defined apriori. |
|
82
|
|
|
|
|
83
|
|
|
:param filename: The name of the file to execute. |
|
84
|
|
|
:param file: The contents of the file as a list of strings. |
|
85
|
|
|
""" |
|
86
|
|
|
assert ((self.use_stdin and file is not None) or |
|
87
|
|
|
(not self.use_stdin and filename is not None)) |
|
88
|
|
|
|
|
89
|
|
|
config_file = self.generate_config_file() |
|
90
|
|
|
self.command = self._create_command(filename=filename, |
|
91
|
|
|
config_file=config_file) |
|
92
|
|
|
|
|
93
|
|
|
stdin_input = "".join(file) if self.use_stdin else None |
|
94
|
|
|
stdout_output, stderr_output = run_shell_command(self.command, |
|
95
|
|
|
stdin=stdin_input) |
|
96
|
|
|
self.stdout_output = tuple(stdout_output.splitlines(keepends=True)) |
|
97
|
|
|
self.stderr_output = tuple(stderr_output.splitlines(keepends=True)) |
|
98
|
|
|
results_output = (self.stderr_output if self.use_stderr |
|
99
|
|
|
else self.stdout_output) |
|
100
|
|
|
results = self.process_output(results_output, filename, file) |
|
101
|
|
|
if not self.use_stderr: |
|
102
|
|
|
self._print_errors(self.stderr_output) |
|
103
|
|
|
|
|
104
|
|
|
if config_file: |
|
105
|
|
|
os.remove(config_file) |
|
106
|
|
|
|
|
107
|
|
|
return results |
|
108
|
|
|
|
|
109
|
|
|
def process_output(self, output, filename, file): |
|
110
|
|
|
""" |
|
111
|
|
|
Take the output (from stdout or stderr) and use it to create Results. |
|
112
|
|
|
If the class variable ``gives_corrected`` is set to True, the |
|
113
|
|
|
``_process_corrected()`` is called. If it is False, |
|
114
|
|
|
``_process_issues()`` is called. |
|
115
|
|
|
|
|
116
|
|
|
:param output: The output to be used to obtain Results from. The |
|
117
|
|
|
output is either stdout or stderr depending on the |
|
118
|
|
|
class variable ``use_stderr``. |
|
119
|
|
|
:param filename: The name of the file whose output is being processed. |
|
120
|
|
|
:param file: The contents of the file whose output is being |
|
121
|
|
|
processed. |
|
122
|
|
|
:return: Generator which gives Results produced based on this |
|
123
|
|
|
output. |
|
124
|
|
|
""" |
|
125
|
|
|
if self.gives_corrected: |
|
126
|
|
|
return self._process_corrected(output, filename, file) |
|
127
|
|
|
else: |
|
128
|
|
|
return self._process_issues(output, filename) |
|
129
|
|
|
|
|
130
|
|
|
def _process_corrected(self, output, filename, file): |
|
131
|
|
|
""" |
|
132
|
|
|
Process the output and use it to create Results by creating diffs. |
|
133
|
|
|
The diffs are created by comparing the output and the original file. |
|
134
|
|
|
|
|
135
|
|
|
:param output: The corrected file contents. |
|
136
|
|
|
:param filename: The name of the file. |
|
137
|
|
|
:param file: The original contents of the file. |
|
138
|
|
|
:return: Generator which gives Results produced based on the |
|
139
|
|
|
diffs created by comparing the original and corrected |
|
140
|
|
|
contents. |
|
141
|
|
|
""" |
|
142
|
|
|
for diff in self.__yield_diffs(file, output): |
|
143
|
|
|
yield Result(self, |
|
144
|
|
|
self.diff_message, |
|
145
|
|
|
affected_code=(diff.range(filename),), |
|
146
|
|
|
diffs={filename: diff}, |
|
147
|
|
|
severity=self.diff_severity) |
|
148
|
|
|
|
|
149
|
|
|
def _process_issues(self, output, filename): |
|
150
|
|
|
""" |
|
151
|
|
|
Process the output using the regex provided in ``output_regex`` and |
|
152
|
|
|
use it to create Results by using named captured groups from the regex. |
|
153
|
|
|
|
|
154
|
|
|
:param output: The output to be parsed by regex. |
|
155
|
|
|
:param filename: The name of the file. |
|
156
|
|
|
:param file: The original contents of the file. |
|
157
|
|
|
:return: Generator which gives Results produced based on regex |
|
158
|
|
|
matches using the ``output_regex`` provided and the |
|
159
|
|
|
``output`` parameter. |
|
160
|
|
|
""" |
|
161
|
|
|
regex = self.output_regex |
|
162
|
|
|
if isinstance(regex, str): |
|
163
|
|
|
regex = regex % {"file_name": filename} |
|
164
|
|
|
|
|
165
|
|
|
# Note: We join ``output`` because the regex may want to capture |
|
166
|
|
|
# multiple lines also. |
|
167
|
|
|
for match in re.finditer(regex, "".join(output)): |
|
168
|
|
|
yield self.match_to_result(match, filename) |
|
169
|
|
|
|
|
170
|
|
|
def _get_groupdict(self, match): |
|
171
|
|
|
""" |
|
172
|
|
|
Convert a regex match's groups into a dictionary with data to be used |
|
173
|
|
|
to create a Result. This is used internally in ``match_to_result``. |
|
174
|
|
|
|
|
175
|
|
|
:param match: The match got from regex parsing. |
|
176
|
|
|
:param filename: The name of the file from which this match is got. |
|
177
|
|
|
:return: The dictionary containing the information: |
|
178
|
|
|
- line - The line where the result starts. |
|
179
|
|
|
- column - The column where the result starts. |
|
180
|
|
|
- end_line - The line where the result ends. |
|
181
|
|
|
- end_column - The column where the result ends. |
|
182
|
|
|
- severity - The severity of the result. |
|
183
|
|
|
- message - The message of the result. |
|
184
|
|
|
- origin - The origin of the result. |
|
185
|
|
|
""" |
|
186
|
|
|
groups = match.groupdict() |
|
187
|
|
|
if ( |
|
188
|
|
|
isinstance(self.severity_map, dict) and |
|
189
|
|
|
"severity" in groups and |
|
190
|
|
|
groups["severity"] in self.severity_map): |
|
191
|
|
|
groups["severity"] = self.severity_map[groups["severity"]] |
|
192
|
|
|
return groups |
|
193
|
|
|
|
|
194
|
|
|
def _create_command(self, **kwargs): |
|
195
|
|
|
command = self.executable + ' ' + self.arguments |
|
196
|
|
|
for key in ("filename", "config_file"): |
|
197
|
|
|
kwargs[key] = escape_path_argument(kwargs.get(key, "") or "") |
|
198
|
|
|
return command.format(**kwargs) |
|
199
|
|
|
|
|
200
|
|
|
def _print_errors(self, errors): |
|
201
|
|
|
for line in filter(lambda error: bool(error.strip()), errors): |
|
202
|
|
|
self.warn(line) |
|
203
|
|
|
|
|
204
|
|
|
@staticmethod |
|
205
|
|
|
def __yield_diffs(file, new_file): |
|
206
|
|
|
if tuple(new_file) != tuple(file): |
|
207
|
|
|
wholediff = Diff.from_string_arrays(file, new_file) |
|
208
|
|
|
|
|
209
|
|
|
for diff in wholediff.split_diff(): |
|
210
|
|
|
yield diff |
|
211
|
|
|
|
|
212
|
|
|
def match_to_result(self, match, filename): |
|
213
|
|
|
""" |
|
214
|
|
|
Convert a regex match's groups into a coala Result object. |
|
215
|
|
|
|
|
216
|
|
|
:param match: The match got from regex parsing. |
|
217
|
|
|
:param filename: The name of the file from which this match is got. |
|
218
|
|
|
:return: The Result object. |
|
219
|
|
|
""" |
|
220
|
|
|
groups = self._get_groupdict(match) |
|
221
|
|
|
|
|
222
|
|
|
# Pre process the groups |
|
223
|
|
|
for variable in ("line", "column", "end_line", "end_column"): |
|
224
|
|
|
if variable in groups and groups[variable]: |
|
225
|
|
|
groups[variable] = int(groups[variable]) |
|
226
|
|
|
|
|
227
|
|
|
if "origin" in groups: |
|
228
|
|
|
groups['origin'] = "{} ({})".format(str(self.__class__.__name__), |
|
229
|
|
|
str(groups["origin"])) |
|
230
|
|
|
|
|
231
|
|
|
return Result.from_values( |
|
232
|
|
|
origin=groups.get("origin", self), |
|
233
|
|
|
message=groups.get("message", ""), |
|
234
|
|
|
file=filename, |
|
235
|
|
|
severity=int(groups.get("severity", RESULT_SEVERITY.NORMAL)), |
|
236
|
|
|
line=groups.get("line", None), |
|
237
|
|
|
column=groups.get("column", None), |
|
238
|
|
|
end_line=groups.get("end_line", None), |
|
239
|
|
|
end_column=groups.get("end_column", None)) |
|
240
|
|
|
|
|
241
|
|
|
@classmethod |
|
242
|
|
|
def check_prerequisites(cls): |
|
243
|
|
|
""" |
|
244
|
|
|
Checks for prerequisites required by the Linter Bear. |
|
245
|
|
|
|
|
246
|
|
|
It uses the class variables: |
|
247
|
|
|
- ``executable`` - Checks that it is available in the PATH using |
|
248
|
|
|
``shutil.which``. |
|
249
|
|
|
- ``prerequisite_command`` - Checks that when this command is run, |
|
250
|
|
|
the exitcode is 0. If it is not zero, ``prerequisite_fail_msg`` |
|
251
|
|
|
is gives as the failure message. |
|
252
|
|
|
|
|
253
|
|
|
If either of them is set to ``None`` that check is ignored. |
|
254
|
|
|
|
|
255
|
|
|
:return: True is all checks are valid, else False. |
|
256
|
|
|
""" |
|
257
|
|
|
return cls._check_command(executable=cls.executable, |
|
258
|
|
|
command=cls.prerequisite_command, |
|
259
|
|
|
fail_msg=cls.prerequisite_fail_msg) |
|
260
|
|
|
|
|
261
|
|
|
@classmethod |
|
262
|
|
|
@enforce_signature |
|
263
|
|
|
def _check_command(cls, executable, command: (list, tuple, None), fail_msg): |
|
264
|
|
|
""" |
|
265
|
|
|
Checks whether the required executable is found and the |
|
266
|
|
|
required command succesfully executes. |
|
267
|
|
|
|
|
268
|
|
|
The function is intended be used with classes having an |
|
269
|
|
|
executable, prerequisite_command and prerequisite_fail_msg. |
|
270
|
|
|
|
|
271
|
|
|
:param executable: The executable to check for. |
|
272
|
|
|
:param command: The command to check as a prerequisite. |
|
273
|
|
|
:param fail_msg: The fail message to display when the |
|
274
|
|
|
command doesn't return an exitcode of zero. |
|
275
|
|
|
|
|
276
|
|
|
:return: True if command successfully executes, or is not required. |
|
277
|
|
|
not True otherwise, with a string containing a |
|
278
|
|
|
detailed description of the error. |
|
279
|
|
|
""" |
|
280
|
|
|
if cls._check_executable(executable): |
|
281
|
|
|
if command is None: |
|
282
|
|
|
return True # when there are no prerequisites |
|
283
|
|
|
try: |
|
284
|
|
|
check_call(command, stdout=DEVNULL, stderr=DEVNULL) |
|
285
|
|
|
return True |
|
286
|
|
|
except (OSError, CalledProcessError): |
|
287
|
|
|
return fail_msg |
|
288
|
|
|
else: |
|
289
|
|
|
return repr(executable) + " is not installed." |
|
290
|
|
|
|
|
291
|
|
|
@staticmethod |
|
292
|
|
|
def _check_executable(executable): |
|
293
|
|
|
""" |
|
294
|
|
|
Checks whether the needed executable is present in the system. |
|
295
|
|
|
|
|
296
|
|
|
:param executable: The executable to check for. |
|
297
|
|
|
|
|
298
|
|
|
:return: True if binary is present, or is not required. |
|
299
|
|
|
not True otherwise, with a string containing a |
|
300
|
|
|
detailed description of what's missing. |
|
301
|
|
|
""" |
|
302
|
|
|
if executable is None: |
|
303
|
|
|
return True |
|
304
|
|
|
return shutil.which(executable) is not None |
|
305
|
|
|
|
|
306
|
|
|
def generate_config_file(self): |
|
307
|
|
|
""" |
|
308
|
|
|
Generates a temporary config file. |
|
309
|
|
|
Note: The user of the function is responsible for deleting the |
|
310
|
|
|
tempfile when done with it. |
|
311
|
|
|
|
|
312
|
|
|
:return: The file name of the tempfile created. |
|
313
|
|
|
""" |
|
314
|
|
|
config_lines = self.config_file() |
|
315
|
|
|
config_file = "" |
|
316
|
|
|
if config_lines is not None: |
|
317
|
|
|
for i, line in enumerate(config_lines): |
|
318
|
|
|
config_lines[i] = line if line.endswith("\n") else line + "\n" |
|
319
|
|
|
config_fd, config_file = tempfile.mkstemp() |
|
320
|
|
|
os.close(config_fd) |
|
321
|
|
|
with open(config_file, 'w') as conf_file: |
|
322
|
|
|
conf_file.writelines(config_lines) |
|
323
|
|
|
return config_file |
|
324
|
|
|
|
|
325
|
|
|
@staticmethod |
|
326
|
|
|
def config_file(): |
|
327
|
|
|
""" |
|
328
|
|
|
Returns a configuation file from the section given to the bear. |
|
329
|
|
|
The section is available in ``self.section``. To add the config |
|
330
|
|
|
file's name generated by this function to the arguments, |
|
331
|
|
|
use ``{config_file}``. |
|
332
|
|
|
|
|
333
|
|
|
:return: A list of lines of the config file to be used or None. |
|
334
|
|
|
""" |
|
335
|
|
|
return None |
|
336
|
|
|
|