blob: 68179aafd745594f52b66d300319c4959863c512 [file] [log] [blame]
Jordan Baylesf34bf242019-10-11 11:19:00 -07001#!/usr/bin/env python
2"""A wrapper script around clang-format, suitable for linting multiple files
3and to use for continuous integration.
4This is an alternative API for the clang-format command line.
5It runs over multiple files and directories in parallel.
6A diff output is produced and a sensible exit code is returned.
7
8NOTE: pulled from https://github.com/Sarcasm/run-clang-format, which is
9licensed under the MIT license.
10"""
11
12from __future__ import print_function, unicode_literals
13
14import argparse
15import codecs
16import difflib
17import fnmatch
18import io
19import multiprocessing
20import os
21import signal
22import subprocess
23import sys
24import traceback
25
26from functools import partial
27
28try:
29 from subprocess import DEVNULL # py3k
30except ImportError:
31 DEVNULL = open(os.devnull, "wb")
32
33
34DEFAULT_EXTENSIONS = 'c,h,C,H,cpp,hpp,cc,hh,c++,h++,cxx,hxx'
35
36
37class ExitStatus:
38 SUCCESS = 0
39 DIFF = 1
40 TROUBLE = 2
41
42
43def list_files(files, recursive=False, extensions=None, exclude=None):
44 if extensions is None:
45 extensions = []
46 if exclude is None:
47 exclude = []
48
49 out = []
50 for file in files:
51 if recursive and os.path.isdir(file):
52 for dirpath, dnames, fnames in os.walk(file):
53 fpaths = [os.path.join(dirpath, fname) for fname in fnames]
54 for pattern in exclude:
55 # os.walk() supports trimming down the dnames list
56 # by modifying it in-place,
57 # to avoid unnecessary directory listings.
58 dnames[:] = [
59 x for x in dnames
60 if
61 not fnmatch.fnmatch(os.path.join(dirpath, x), pattern)
62 ]
63 fpaths = [
64 x for x in fpaths if not fnmatch.fnmatch(x, pattern)
65 ]
66 for f in fpaths:
67 ext = os.path.splitext(f)[1][1:]
68 if ext in extensions:
69 out.append(f)
70 else:
71 out.append(file)
72 return out
73
74
75def make_diff(file, original, reformatted):
76 return list(
77 difflib.unified_diff(
78 original,
79 reformatted,
80 fromfile='{}\t(original)'.format(file),
81 tofile='{}\t(reformatted)'.format(file),
82 n=3))
83
84
85class DiffError(Exception):
86 def __init__(self, message, errs=None):
87 super(DiffError, self).__init__(message)
88 self.errs = errs or []
89
90
91class UnexpectedError(Exception):
92 def __init__(self, message, exc=None):
93 super(UnexpectedError, self).__init__(message)
94 self.formatted_traceback = traceback.format_exc()
95 self.exc = exc
96
97
98def run_clang_format_diff_wrapper(args, file):
99 try:
100 ret = run_clang_format_diff(args, file)
101 return ret
102 except DiffError:
103 raise
104 except Exception as e:
105 raise UnexpectedError('{}: {}: {}'.format(file, e.__class__.__name__,
106 e), e)
107
108
109def run_clang_format_diff(args, file):
110 try:
111 with io.open(file, 'r', encoding='utf-8') as f:
112 original = f.readlines()
113 except IOError as exc:
114 raise DiffError(str(exc))
115 invocation = [args.clang_format_executable, file]
116
117 # Use of utf-8 to decode the process output.
118 #
119 # Hopefully, this is the correct thing to do.
120 #
121 # It's done due to the following assumptions (which may be incorrect):
122 # - clang-format will returns the bytes read from the files as-is,
123 # without conversion, and it is already assumed that the files use utf-8.
124 # - if the diagnostics were internationalized, they would use utf-8:
125 # > Adding Translations to Clang
126 # >
127 # > Not possible yet!
128 # > Diagnostic strings should be written in UTF-8,
129 # > the client can translate to the relevant code page if needed.
130 # > Each translation completely replaces the format string
131 # > for the diagnostic.
132 # > -- http://clang.llvm.org/docs/InternalsManual.html#internals-diag-translation
133 #
134 # It's not pretty, due to Python 2 & 3 compatibility.
135 encoding_py3 = {}
136 if sys.version_info[0] >= 3:
137 encoding_py3['encoding'] = 'utf-8'
138
139 try:
140 proc = subprocess.Popen(
141 invocation,
142 stdout=subprocess.PIPE,
143 stderr=subprocess.PIPE,
144 universal_newlines=True,
145 **encoding_py3)
146 except OSError as exc:
147 raise DiffError(
148 "Command '{}' failed to start: {}".format(
149 subprocess.list2cmdline(invocation), exc
150 )
151 )
152 proc_stdout = proc.stdout
153 proc_stderr = proc.stderr
154 if sys.version_info[0] < 3:
155 # make the pipes compatible with Python 3,
156 # reading lines should output unicode
157 encoding = 'utf-8'
158 proc_stdout = codecs.getreader(encoding)(proc_stdout)
159 proc_stderr = codecs.getreader(encoding)(proc_stderr)
160 # hopefully the stderr pipe won't get full and block the process
161 outs = list(proc_stdout.readlines())
162 errs = list(proc_stderr.readlines())
163 proc.wait()
164 if proc.returncode:
165 raise DiffError(
166 "Command '{}' returned non-zero exit status {}".format(
167 subprocess.list2cmdline(invocation), proc.returncode
168 ),
169 errs,
170 )
171 return make_diff(file, original, outs), errs
172
173
174def bold_red(s):
175 return '\x1b[1m\x1b[31m' + s + '\x1b[0m'
176
177
178def colorize(diff_lines):
179 def bold(s):
180 return '\x1b[1m' + s + '\x1b[0m'
181
182 def cyan(s):
183 return '\x1b[36m' + s + '\x1b[0m'
184
185 def green(s):
186 return '\x1b[32m' + s + '\x1b[0m'
187
188 def red(s):
189 return '\x1b[31m' + s + '\x1b[0m'
190
191 for line in diff_lines:
192 if line[:4] in ['--- ', '+++ ']:
193 yield bold(line)
194 elif line.startswith('@@ '):
195 yield cyan(line)
196 elif line.startswith('+'):
197 yield green(line)
198 elif line.startswith('-'):
199 yield red(line)
200 else:
201 yield line
202
203
204def print_diff(diff_lines, use_color):
205 if use_color:
206 diff_lines = colorize(diff_lines)
207 if sys.version_info[0] < 3:
208 sys.stdout.writelines((l.encode('utf-8') for l in diff_lines))
209 else:
210 sys.stdout.writelines(diff_lines)
211
212
213def print_trouble(prog, message, use_colors):
214 error_text = 'error:'
215 if use_colors:
216 error_text = bold_red(error_text)
217 print("{}: {} {}".format(prog, error_text, message), file=sys.stderr)
218
219
220def main():
221 parser = argparse.ArgumentParser(description=__doc__)
222 parser.add_argument(
223 '--clang-format-executable',
224 metavar='EXECUTABLE',
225 help='path to the clang-format executable',
226 default='clang-format')
227 parser.add_argument(
228 '--extensions',
229 help='comma separated list of file extensions (default: {})'.format(
230 DEFAULT_EXTENSIONS),
231 default=DEFAULT_EXTENSIONS)
232 parser.add_argument(
233 '-r',
234 '--recursive',
235 action='store_true',
236 help='run recursively over directories')
237 parser.add_argument('files', metavar='file', nargs='+')
238 parser.add_argument(
239 '-q',
240 '--quiet',
241 action='store_true')
242 parser.add_argument(
243 '-j',
244 metavar='N',
245 type=int,
246 default=0,
247 help='run N clang-format jobs in parallel'
248 ' (default number of cpus + 1)')
249 parser.add_argument(
250 '--color',
251 default='auto',
252 choices=['auto', 'always', 'never'],
253 help='show colored diff (default: auto)')
254 parser.add_argument(
255 '-e',
256 '--exclude',
257 metavar='PATTERN',
258 action='append',
259 default=[],
260 help='exclude paths matching the given glob-like pattern(s)'
261 ' from recursive search')
262
263 args = parser.parse_args()
264
265 # use default signal handling, like diff return SIGINT value on ^C
266 # https://bugs.python.org/issue14229#msg156446
267 signal.signal(signal.SIGINT, signal.SIG_DFL)
268 try:
269 signal.SIGPIPE
270 except AttributeError:
271 # compatibility, SIGPIPE does not exist on Windows
272 pass
273 else:
274 signal.signal(signal.SIGPIPE, signal.SIG_DFL)
275
276 colored_stdout = False
277 colored_stderr = False
278 if args.color == 'always':
279 colored_stdout = True
280 colored_stderr = True
281 elif args.color == 'auto':
282 colored_stdout = sys.stdout.isatty()
283 colored_stderr = sys.stderr.isatty()
284
285 version_invocation = [args.clang_format_executable, str("--version")]
286 try:
287 subprocess.check_call(version_invocation, stdout=DEVNULL)
288 except subprocess.CalledProcessError as e:
289 print_trouble(parser.prog, str(e), use_colors=colored_stderr)
290 return ExitStatus.TROUBLE
291 except OSError as e:
292 print_trouble(
293 parser.prog,
294 "Command '{}' failed to start: {}".format(
295 subprocess.list2cmdline(version_invocation), e
296 ),
297 use_colors=colored_stderr,
298 )
299 return ExitStatus.TROUBLE
300
301 retcode = ExitStatus.SUCCESS
302 files = list_files(
303 args.files,
304 recursive=args.recursive,
305 exclude=args.exclude,
306 extensions=args.extensions.split(','))
307
308 if not files:
309 return
310
311 njobs = args.j
312 if njobs == 0:
313 njobs = multiprocessing.cpu_count() + 1
314 njobs = min(len(files), njobs)
315
316 if njobs == 1:
317 # execute directly instead of in a pool,
318 # less overhead, simpler stacktraces
319 it = (run_clang_format_diff_wrapper(args, file) for file in files)
320 pool = None
321 else:
322 pool = multiprocessing.Pool(njobs)
323 it = pool.imap_unordered(
324 partial(run_clang_format_diff_wrapper, args), files)
325 while True:
326 try:
327 outs, errs = next(it)
328 except StopIteration:
329 break
330 except DiffError as e:
331 print_trouble(parser.prog, str(e), use_colors=colored_stderr)
332 retcode = ExitStatus.TROUBLE
333 sys.stderr.writelines(e.errs)
334 except UnexpectedError as e:
335 print_trouble(parser.prog, str(e), use_colors=colored_stderr)
336 sys.stderr.write(e.formatted_traceback)
337 retcode = ExitStatus.TROUBLE
338 # stop at the first unexpected error,
339 # something could be very wrong,
340 # don't process all files unnecessarily
341 if pool:
342 pool.terminate()
343 break
344 else:
345 sys.stderr.writelines(errs)
346 if outs == []:
347 continue
348 if not args.quiet:
349 print_diff(outs, use_color=colored_stdout)
350 if retcode == ExitStatus.SUCCESS:
351 retcode = ExitStatus.DIFF
352 return retcode
353
354
355if __name__ == '__main__':
356 sys.exit(main())