1#!/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())
357