2
2
import fnmatch
3
3
import glob
4
4
import re
5
+ import sys
5
6
from collections import namedtuple
6
7
from itertools import chain
7
8
8
9
import yaml
9
10
10
11
12
+ GIT_DIFF_LINE_NUMBERS_PATTERN = re .compile (
13
+ r"@ -\d+(,\d+)? \+(\d+)(,)?(\d+)? @" )
14
+ GIT_DIFF_FILENAME_PATTERN = re .compile (
15
+ r"(?:\n|^)diff --git a\/.* b\/(.*)(?:\n|$)" )
16
+ GIT_DIFF_SPLIT_PATTERN = re .compile (
17
+ r"(?:\n|^)diff --git a\/.* b\/.*(?:\n|$)" )
18
+
19
+
20
+ Test = namedtuple ('Test' , ('name' , 'pattern' , 'hint' , 'filename' , 'error' ))
21
+
22
+
11
23
def parse_args ():
12
24
parser = argparse .ArgumentParser ()
13
25
parser .add_argument (
@@ -18,19 +30,22 @@ def parse_args():
18
30
help = 'Path to one or multiple files to be checked.'
19
31
)
20
32
parser .add_argument (
21
- '--config' ,
22
33
'-c' ,
34
+ '--config' ,
23
35
metavar = 'CONFIG_FILE' ,
24
36
type = str ,
25
37
default = '.relint.yml' ,
26
38
help = 'Path to config file, default: .relint.yml'
27
39
)
40
+ parser .add_argument (
41
+ '-d' ,
42
+ '--diff' ,
43
+ action = 'store_true' ,
44
+ help = 'Analyze content from git diff.'
45
+ )
28
46
return parser .parse_args ()
29
47
30
48
31
- Test = namedtuple ('Test' , ('name' , 'pattern' , 'hint' , 'filename' , 'error' ))
32
-
33
-
34
49
def load_config (path ):
35
50
with open (path ) as fs :
36
51
for test in yaml .load (fs ):
@@ -56,31 +71,77 @@ def lint_file(filename, tests):
56
71
for test in tests :
57
72
if any (fnmatch .fnmatch (filename , fp ) for fp in test .filename ):
58
73
for match in test .pattern .finditer (content ):
59
- yield filename , test , match
74
+ line_number = match .string [:match .start ()].count ('\n ' ) + 1
75
+ yield filename , test , match , line_number
60
76
61
77
62
- def main ():
63
- args = parse_args ()
64
- paths = {
65
- path
66
- for file in args .files
67
- for path in glob .iglob (file , recursive = True )
68
- }
78
+ def parse_line_numbers (output ):
79
+ """
80
+ Extract line numbers from ``git diff`` output.
69
81
70
- tests = list (load_config (args .config ))
82
+ Git shows which lines were changed indicating a start line
83
+ and how many lines were changed from that. If only one
84
+ line was changed, the output will display only the start line,
85
+ like this:
86
+ ``@@ -54 +54 @@ import glob``
87
+ If more lines were changed from that point, it will show
88
+ how many after a comma:
89
+ ``@@ -4,2 +4,2 @@ import glob``
90
+ It means that line number 4 and the following 2 lines were changed
91
+ (5 and 6).
71
92
72
- matches = chain .from_iterable (
73
- lint_file (path , tests )
74
- for path in paths
75
- )
93
+ Args:
94
+ output (int): ``git diff`` output.
95
+
96
+ Returns:
97
+ list: All changed line numbers.
98
+ """
99
+ line_numbers = []
100
+ matches = GIT_DIFF_LINE_NUMBERS_PATTERN .finditer (output )
101
+
102
+ for match in matches :
103
+ start = int (match .group (2 ))
104
+ if match .group (4 ) is not None :
105
+ end = start + int (match .group (4 ))
106
+ line_numbers .extend (range (start , end ))
107
+ else :
108
+ line_numbers .append (start )
109
+
110
+ return line_numbers
111
+
112
+
113
+ def parse_filenames (output ):
114
+ return re .findall (GIT_DIFF_FILENAME_PATTERN , output )
76
115
77
- _filename = ''
78
- lines = []
79
116
117
+ def split_diff_content_by_filename (output ):
118
+ """
119
+ Split the output by filename.
120
+
121
+ Args:
122
+ output (int): ``git diff`` output.
123
+
124
+ Returns:
125
+ dict: Filename and its content.
126
+ """
127
+ content_by_filename = {}
128
+ filenames = parse_filenames (output )
129
+ splited_content = re .split (GIT_DIFF_SPLIT_PATTERN , output )
130
+ splited_content = filter (lambda x : x != '' , splited_content )
131
+
132
+ for filename , content in zip (filenames , splited_content ):
133
+ content_by_filename [filename ] = content
134
+ return content_by_filename
135
+
136
+
137
+ def print_culprits (matches ):
80
138
exit_code = 0
139
+ _filename = ''
140
+ lines = []
81
141
82
- for filename , test , match in matches :
142
+ for filename , test , match , _ in matches :
83
143
exit_code = test .error if exit_code == 0 else exit_code
144
+
84
145
if filename != _filename :
85
146
_filename = filename
86
147
lines = match .string .splitlines ()
@@ -102,6 +163,46 @@ def main():
102
163
)
103
164
print (* match_lines , sep = "\n " )
104
165
166
+ return exit_code
167
+
168
+
169
+ def match_with_diff_changes (content , matches ):
170
+ """Check matches found on diff output."""
171
+ for filename , test , match , line_number in matches :
172
+ if content .get (filename ) and line_number in content .get (filename ):
173
+ yield filename , test , match , line_number
174
+
175
+
176
+ def parse_diff (output ):
177
+ """Parse changed content by file."""
178
+ changed_content = {}
179
+ for filename , content in split_diff_content_by_filename (output ).items ():
180
+ changed_line_numbers = parse_line_numbers (content )
181
+ changed_content [filename ] = changed_line_numbers
182
+ return changed_content
183
+
184
+
185
+ def main ():
186
+ args = parse_args ()
187
+ paths = {
188
+ path
189
+ for file in args .files
190
+ for path in glob .iglob (file , recursive = True )
191
+ }
192
+
193
+ tests = list (load_config (args .config ))
194
+
195
+ matches = chain .from_iterable (
196
+ lint_file (path , tests )
197
+ for path in paths
198
+ )
199
+
200
+ if args .diff :
201
+ output = sys .stdin .read ()
202
+ changed_content = parse_diff (output )
203
+ matches = match_with_diff_changes (changed_content , matches )
204
+
205
+ exit_code = print_culprits (matches )
105
206
exit (exit_code )
106
207
107
208
0 commit comments