gitlab-ci: Lint the linters
[ghc.git] / .gitlab / linters / linter.py
1 """
2 Utilities for linters
3 """
4
5 import os
6 import sys
7 import re
8 import textwrap
9 import subprocess
10 from pathlib import Path
11 from typing import List, Optional, Callable, Sequence
12 from collections import namedtuple
13
14 def lint_failure(file, line_no: int, line_content: str, message: str):
15 """ Print a lint failure message. """
16 wrapper = textwrap.TextWrapper(initial_indent=' ',
17 subsequent_indent=' ')
18 body = wrapper.fill(message)
19 msg = '''
20 {file}:
21
22 |
23 {line_no:5d} | {line_content}
24 |
25
26 {body}
27 '''.format(file=file, line_no=line_no,
28 line_content=line_content,
29 body=body)
30
31 print(textwrap.dedent(msg))
32
33 def get_changed_files(base_commit: str, head_commit: str,
34 subdir: str = '.'):
35 """ Get the files changed by the given range of commits. """
36 cmd = ['git', 'diff', '--name-only',
37 base_commit, head_commit, '--', subdir]
38 files = subprocess.check_output(cmd)
39 return files.decode('UTF-8').split('\n')
40
41 Warning = namedtuple('Warning', 'path,line_no,line_content,message')
42
43 class Linter(object):
44 """
45 A :class:`Linter` must implement :func:`lint`, which looks at the
46 given path and calls :func:`add_warning` for any lint issues found.
47 """
48 def __init__(self):
49 self.warnings = [] # type: List[Warning]
50 self.path_filters = [] # type: List[Callable[[Path], bool]]
51
52 def add_warning(self, w: Warning):
53 self.warnings.append(w)
54
55 def add_path_filter(self, f: Callable[[Path], bool]) -> "Linter":
56 self.path_filters.append(f)
57 return self
58
59 def do_lint(self, path: Path):
60 if all(f(path) for f in self.path_filters):
61 self.lint(path)
62
63 def lint(self, path: Path):
64 raise NotImplementedError
65
66 class LineLinter(Linter):
67 """
68 A :class:`LineLinter` must implement :func:`lint_line`, which looks at
69 the given line from a file and calls :func:`add_warning` for any lint
70 issues found.
71 """
72 def lint(self, path: Path):
73 if path.is_file():
74 with path.open('r') as f:
75 for line_no, line in enumerate(f):
76 self.lint_line(path, line_no+1, line)
77
78 def lint_line(self, path: Path, line_no: int, line: str):
79 raise NotImplementedError
80
81 class RegexpLinter(LineLinter):
82 """
83 A :class:`RegexpLinter` produces the given warning message for
84 all lines matching the given regular expression.
85 """
86 def __init__(self, regex: str, message: str):
87 LineLinter.__init__(self)
88 self.re = re.compile(regex)
89 self.message = message
90
91 def lint_line(self, path: Path, line_no: int, line: str):
92 if self.re.search(line):
93 w = Warning(path=path, line_no=line_no, line_content=line[:-1],
94 message=self.message)
95 self.add_warning(w)
96
97 def run_linters(linters: Sequence[Linter],
98 subdir: str = '.') -> None:
99 import argparse
100 parser = argparse.ArgumentParser()
101 parser.add_argument('base', help='Base commit')
102 parser.add_argument('head', help='Head commit')
103 args = parser.parse_args()
104
105 for path in get_changed_files(args.base, args.head, subdir):
106 if path.startswith('.gitlab/linters'):
107 continue
108 for linter in linters:
109 linter.do_lint(Path(path))
110
111 warnings = [warning
112 for linter in linters
113 for warning in linter.warnings]
114 warnings = sorted(warnings, key=lambda x: (x.path, x.line_no))
115 for w in warnings:
116 lint_failure(w.path, w.line_no, w.line_content, w.message)
117
118 if len(warnings) > 0:
119 sys.exit(1)