Completed
Pull Request — master (#1522)
by Abdeali
02:07
created

coalib.bearlib.abstractions.Lint   B

Complexity

Total Complexity 36

Size/Duplication

Total Lines 186
Duplicated Lines 0 %
Metric Value
dl 0
loc 186
rs 8.8
wmc 36

11 Methods

Rating   Name   Duplication   Size   Complexity  
A _process_corrected() 0 7 2
B lint() 0 28 6
A process_output() 0 5 2
A _get_groupdict() 0 8 4
A _process_issues() 0 9 3
A __print_errors() 0 3 3
B match_to_result() 0 27 5
B generate_config_file() 0 18 5
A config_file() 0 11 1
A _create_command() 0 5 2
A __yield_diffs() 0 7 3
1
import os
2
import re
3
import shutil
4
import tempfile
5
6
from coalib.bears.Bear import Bear
7
from coalib.misc.Shell import escape_path_argument, run_shell_command
8
from coalib.results.Diff import Diff
9
from coalib.results.Result import Result
10
from coalib.results.RESULT_SEVERITY import RESULT_SEVERITY
11
12
13
def is_binary_present(cls):
14
    """
15
    Checks whether the needed binary is present.
16
17
    The function is intended be used with classes
18
    having an executable member which will be checked.
19
20
    :return: True if binary is present, or is not required.
21
             not True otherwise, with a string containing a
22
             detailed description of what's missing.
23
    """
24
    try:
25
        if cls.executable is None:
26
            return True
27
        if shutil.which(cls.executable) is None:
28
            return repr(cls.executable) + " is not installed."
29
        else:
30
            return True
31
    except AttributeError:
32
        # Happens when `executable` does not exist in `cls`.
33
        return True
34
35
36
class Lint(Bear):
37
    """
38
    :param executable:      The executable to run the linter.
39
    :param arguments:       The arguments to supply to the linter, such
40
                            that the file name to be analyzed can be
41
                            appended to the end. Note that we use .format()
42
                            on the arguments - so, `{abc}` needs to be given
43
                            as `{{abc}}`.
44
                            Currently, the following will be replaced:
45
                            {filename}    - the filename passed to lint()
46
                            {config_file} - The config file created using
47
                                            config_file()
48
    :param output_regex:    The regex which will match the output of the linter
49
                            to get results. This regex should give out the
50
                            following variables:
51
                             line - The line where the issue starts.
52
                             column - The column where the issue starts.
53
                             end_line - The line where the issue ends.
54
                             end_column - The column where the issue ends.
55
                             severity - The severity of the issue.
56
                             message - The message of the result.
57
                             origin - The origin of the issue.
58
                            This is not used if `gives_corrected` is set.
59
    :param diff_severity:   The severity to use for all results if
60
                            `gives_corrected` is set.
61
    :param diff_message:    The message to use for all results if
62
                            `gives_corrected` is set.
63
    :param use_stderr:      Uses stderr as the output stream is it's True.
64
    :param use_stdin:       Sends file as stdin instead of giving the file name.
65
    :param gives_corrected: True if the executable gives the corrected file
66
                            or just the issues.
67
    :param severity_map:    A dict where the keys are the possible severity
68
                            values the Linter gives out and the values are the
69
                            severity of the coala Result to set it to. If it is
70
                            not a dict, it is ignored.
71
    """
72
    check_prerequisites = classmethod(is_binary_present)
73
    executable = None
74
    arguments = ""
75
    output_regex = re.compile(r'(?P<line>\d+)\.(?P<column>\d+)\|'
76
                              r'(?P<severity>\d+): (?P<message>.*)')
77
    diff_message = 'No result message was set'
78
    diff_severity = RESULT_SEVERITY.NORMAL
79
    use_stderr = False
80
    use_stdin = False
81
    gives_corrected = False
82
    severity_map = None
83
84
    def lint(self, filename=None, file=None):
85
        """
86
        Takes a file and lints it using the linter variables defined apriori.
87
88
        :param filename:  The name of the file to execute.
89
        :param file:      The contents of the file as a list of strings.
90
        """
91
        assert ((self.use_stdin and file is not None) or
92
                (not self.use_stdin and filename is not None))
93
94
        config_file = self.generate_config_file()
95
        command = self._create_command(filename=filename,
96
                                       config_file=config_file)
97
98
        stdin_input = "".join(file) if self.use_stdin else None
99
        stdout_output, stderr_output = run_shell_command(command,
100
                                                         stdin=stdin_input)
101
        stdout_output = tuple(stdout_output.splitlines(keepends=True))
102
        stderr_output = tuple(stderr_output.splitlines(keepends=True))
103
        results_output = stderr_output if self.use_stderr else stdout_output
104
        results = self.process_output(results_output, filename, file)
105
        if not self.use_stderr:
106
            self.__print_errors(stderr_output)
107
108
        if config_file:
109
            os.remove(config_file)
110
111
        return results
112
113
    def process_output(self, output, filename, file):
114
        if self.gives_corrected:
115
            return self._process_corrected(output, filename, file)
116
        else:
117
            return self._process_issues(output, filename)
118
119
    def _process_corrected(self, output, filename, file):
120
        for diff in self.__yield_diffs(file, output):
121
            yield Result(self,
122
                         self.diff_message,
123
                         affected_code=(diff.range(filename),),
124
                         diffs={filename: diff},
125
                         severity=self.diff_severity)
126
127
    def _process_issues(self, output, filename):
128
        regex = self.output_regex
129
        if isinstance(regex, str):
130
            regex = regex % {"file_name": filename}
131
132
        # Note: We join `output` because the regex may want to capture
133
        #       multiple lines also.
134
        for match in re.finditer(regex, "".join(output)):
135
            yield self.match_to_result(match, filename)
136
137
    def _get_groupdict(self, match):
138
        groups = match.groupdict()
139
        if (
140
                isinstance(self.severity_map, dict) and
141
                "severity" in groups and
142
                groups["severity"] in self.severity_map):
143
            groups["severity"] = self.severity_map[groups["severity"]]
144
        return groups
145
146
    def _create_command(self, **kwargs):
147
        command = self.executable + ' ' + self.arguments
148
        for key in ("filename", "config_file"):
149
            kwargs[key] = escape_path_argument(kwargs.get(key, "") or "")
150
        return command.format(**kwargs)
151
152
    def __print_errors(self, errors):
153
        for line in filter(lambda error: bool(error.strip()), errors):
154
            self.warn(line)
155
156
    @staticmethod
157
    def __yield_diffs(file, new_file):
158
        if tuple(new_file) != tuple(file):
159
            wholediff = Diff.from_string_arrays(file, new_file)
160
161
            for diff in wholediff.split_diff():
162
                yield diff
163
164
    def match_to_result(self, match, filename):
165
        """
166
        Converts a regex match's groups into a result.
167
168
        :param match:    The match got from regex parsing.
169
        :param filename: The name of the file from which this match is got.
170
        """
171
        groups = self._get_groupdict(match)
172
173
        # Pre process the groups
174
        for variable in ("line", "column", "end_line", "end_column"):
175
            if variable in groups and groups[variable]:
176
                groups[variable] = int(groups[variable])
177
178
        if "origin" in groups:
179
            groups['origin'] = "{} ({})".format(str(self.__class__.__name__),
180
                                                str(groups["origin"]))
181
182
        return Result.from_values(
183
            origin=groups.get("origin", self),
184
            message=groups.get("message", ""),
185
            file=filename,
186
            severity=int(groups.get("severity", RESULT_SEVERITY.NORMAL)),
187
            line=groups.get("line", None),
188
            column=groups.get("column", None),
189
            end_line=groups.get("end_line", None),
190
            end_column=groups.get("end_column", None))
191
192
    def generate_config_file(self):
193
        """
194
        Generates a temporary config file.
195
        Note: The user of the function is responsible for deleting the
196
        tempfile when done with it.
197
198
        :return: The file name of the tempfile created.
199
        """
200
        config_lines = self.config_file()
201
        config_file = ""
202
        if config_lines is not None:
203
            for i, line in enumerate(config_lines):
204
                config_lines[i] = line if line.endswith("\n") else line + "\n"
205
            config_fd, config_file = tempfile.mkstemp()
206
            os.close(config_fd)
207
            with open(config_file, 'w') as conf_file:
208
                conf_file.writelines(config_lines)
209
        return config_file
210
211
    @staticmethod
212
    def config_file():
213
        """
214
        Returns a configuation file from the section given to the bear.
215
        The section is available in `self.section`. To add the config
216
        file's name generated by this function to the arguments,
217
        use `{config_file}`.
218
219
        :return: A list of lines of the config file to be used or None.
220
        """
221
        return None
222