22import fnmatch
33import glob
44import re
5+ import sys
56from collections import namedtuple
67from itertools import chain
78
89import yaml
910
1011
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+
1123def parse_args ():
1224 parser = argparse .ArgumentParser ()
1325 parser .add_argument (
@@ -18,19 +30,22 @@ def parse_args():
1830 help = 'Path to one or multiple files to be checked.'
1931 )
2032 parser .add_argument (
21- '--config' ,
2233 '-c' ,
34+ '--config' ,
2335 metavar = 'CONFIG_FILE' ,
2436 type = str ,
2537 default = '.relint.yml' ,
2638 help = 'Path to config file, default: .relint.yml'
2739 )
40+ parser .add_argument (
41+ '-d' ,
42+ '--diff' ,
43+ action = 'store_true' ,
44+ help = 'Analyze content from git diff.'
45+ )
2846 return parser .parse_args ()
2947
3048
31- Test = namedtuple ('Test' , ('name' , 'pattern' , 'hint' , 'filename' , 'error' ))
32-
33-
3449def load_config (path ):
3550 with open (path ) as fs :
3651 for test in yaml .load (fs ):
@@ -56,31 +71,77 @@ def lint_file(filename, tests):
5671 for test in tests :
5772 if any (fnmatch .fnmatch (filename , fp ) for fp in test .filename ):
5873 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
6076
6177
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.
6981
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).
7192
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 )
76115
77- _filename = ''
78- lines = []
79116
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 ):
80138 exit_code = 0
139+ _filename = ''
140+ lines = []
81141
82- for filename , test , match in matches :
142+ for filename , test , match , _ in matches :
83143 exit_code = test .error if exit_code == 0 else exit_code
144+
84145 if filename != _filename :
85146 _filename = filename
86147 lines = match .string .splitlines ()
@@ -102,6 +163,46 @@ def main():
102163 )
103164 print (* match_lines , sep = "\n " )
104165
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 )
105206 exit (exit_code )
106207
107208
0 commit comments