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