├── .clang-format ├── .clang-format-ignore ├── .travis.yml ├── LICENSE ├── README.rst ├── run-clang-format.py ├── screenshot.png └── src ├── foo.cpp └── third_party └── qux.cpp /.clang-format: -------------------------------------------------------------------------------- 1 | # using clang-format version 5.0.0 2 | Language: Cpp 3 | BasedOnStyle: LLVM 4 | 5 | AllowShortCaseLabelsOnASingleLine: true 6 | AllowShortFunctionsOnASingleLine: true 7 | AlwaysBreakTemplateDeclarations: true 8 | BinPackArguments: false 9 | BinPackParameters: false 10 | # make adding new members at the end less noisy in diffs 11 | BreakConstructorInitializersBeforeComma: true 12 | ConstructorInitializerAllOnOneLineOrOnePerLine: true 13 | -------------------------------------------------------------------------------- /.clang-format-ignore: -------------------------------------------------------------------------------- 1 | # ignore third_party code from clang-format checks 2 | src/third_party/* 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: cpp 2 | 3 | addons: 4 | apt: 5 | sources: 6 | - llvm-toolchain-trusty-5.0 7 | - key_url: 'http://apt.llvm.org/llvm-snapshot.gpg.key' 8 | packages: 9 | - clang-format-5.0 10 | 11 | script: 12 | - ./run-clang-format.py -r src 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Guillaume Papin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ===================== 2 | run-clang-format.py 3 | ===================== 4 | ---------------------------------------------- 5 | Lint files and directories with clang-format 6 | ---------------------------------------------- 7 | 8 | .. contents:: 9 | :local: 10 | 11 | Introduction 12 | ============ 13 | 14 | A wrapper script around clang-format, suitable for linting multiple files 15 | and to use for continuous integration. 16 | 17 | This is an alternative API for the clang-format command line. 18 | It runs over multiple files and directories in parallel. 19 | A diff output is produced and a sensible exit code is returned. 20 | 21 | .. image:: screenshot.png 22 | 23 | 24 | How to use? 25 | =========== 26 | 27 | Copy `run-clang-format.py `_ in your project, 28 | then run it recursively on directories, or specific files:: 29 | 30 | ./run-clang-format.py -r src include foo.cpp 31 | 32 | It's possible to exclude paths from the recursive search:: 33 | 34 | ./run-clang-format.py -r \ 35 | --exclude src/third_party \ 36 | --exclude '*_test.cpp' \ 37 | src include foo.cpp 38 | 39 | These exclude rules can be put in a ``.clang-format-ignore`` file, 40 | which also supports comments. 41 | 42 | An example configuration is available in this repo:: 43 | 44 | $ cat .clang-format-ignore 45 | # ignore third_party code from clang-format checks 46 | src/third_party/* 47 | 48 | 49 | Continuous integration 50 | ====================== 51 | 52 | Check `.travis.yml <.travis.yml>`_. 53 | 54 | For an example of failure in logs, click the badge (build is broken on purpose): 55 | 56 | .. image:: https://travis-ci.org/Sarcasm/run-clang-format.svg?branch=master 57 | :target: https://travis-ci.org/Sarcasm/run-clang-format 58 | 59 | 60 | FAQ 61 | === 62 | 63 | Can I check only changed files? 64 | ------------------------------- 65 | 66 | No, and this is what this repository was initially about. 67 | However, once working around a few shortcommings of ``git clang-format``, 68 | I opted to try an alternative strategy 69 | which expects the whole project to be correctly formatted. 70 | 71 | It would make sense to support this feature as well, 72 | so that the coding style does not need to be enforced but merely suggested. 73 | -------------------------------------------------------------------------------- /run-clang-format.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """A wrapper script around clang-format, suitable for linting multiple files 3 | and to use for continuous integration. 4 | 5 | This is an alternative API for the clang-format command line. 6 | It runs over multiple files and directories in parallel. 7 | A diff output is produced and a sensible exit code is returned. 8 | 9 | """ 10 | 11 | from __future__ import print_function, unicode_literals 12 | 13 | import argparse 14 | import codecs 15 | import difflib 16 | import fnmatch 17 | import io 18 | import errno 19 | import multiprocessing 20 | import os 21 | import signal 22 | import subprocess 23 | import sys 24 | import traceback 25 | 26 | from functools import partial 27 | 28 | try: 29 | from subprocess import DEVNULL # py3k 30 | except ImportError: 31 | DEVNULL = open(os.devnull, "wb") 32 | 33 | 34 | DEFAULT_EXTENSIONS = 'c,h,C,H,cpp,hpp,cc,hh,c++,h++,cxx,hxx' 35 | DEFAULT_CLANG_FORMAT_IGNORE = '.clang-format-ignore' 36 | 37 | 38 | class ExitStatus: 39 | SUCCESS = 0 40 | DIFF = 1 41 | TROUBLE = 2 42 | 43 | def excludes_from_file(ignore_file): 44 | excludes = [] 45 | try: 46 | with io.open(ignore_file, 'r', encoding='utf-8') as f: 47 | for line in f: 48 | if line.startswith('#'): 49 | # ignore comments 50 | continue 51 | pattern = line.rstrip() 52 | if not pattern: 53 | # allow empty lines 54 | continue 55 | excludes.append(pattern) 56 | except EnvironmentError as e: 57 | if e.errno != errno.ENOENT: 58 | raise 59 | return excludes; 60 | 61 | def list_files(files, recursive=False, extensions=None, exclude=None): 62 | if extensions is None: 63 | extensions = [] 64 | if exclude is None: 65 | exclude = [] 66 | 67 | out = [] 68 | for file in files: 69 | if recursive and os.path.isdir(file): 70 | for dirpath, dnames, fnames in os.walk(file): 71 | fpaths = [os.path.join(dirpath, fname) for fname in fnames] 72 | for pattern in exclude: 73 | # os.walk() supports trimming down the dnames list 74 | # by modifying it in-place, 75 | # to avoid unnecessary directory listings. 76 | dnames[:] = [ 77 | x for x in dnames 78 | if 79 | not fnmatch.fnmatch(os.path.join(dirpath, x), pattern) 80 | ] 81 | fpaths = [ 82 | x for x in fpaths if not fnmatch.fnmatch(x, pattern) 83 | ] 84 | for f in fpaths: 85 | ext = os.path.splitext(f)[1][1:] 86 | if ext in extensions: 87 | out.append(f) 88 | else: 89 | out.append(file) 90 | return out 91 | 92 | 93 | def make_diff(file, original, reformatted): 94 | return list( 95 | difflib.unified_diff( 96 | original, 97 | reformatted, 98 | fromfile='{}\t(original)'.format(file), 99 | tofile='{}\t(reformatted)'.format(file), 100 | n=3)) 101 | 102 | 103 | class DiffError(Exception): 104 | def __init__(self, message, errs=None): 105 | super(DiffError, self).__init__(message) 106 | self.errs = errs or [] 107 | 108 | 109 | class UnexpectedError(Exception): 110 | def __init__(self, message, exc=None): 111 | super(UnexpectedError, self).__init__(message) 112 | self.formatted_traceback = traceback.format_exc() 113 | self.exc = exc 114 | 115 | 116 | def run_clang_format_diff_wrapper(args, file): 117 | try: 118 | ret = run_clang_format_diff(args, file) 119 | return ret 120 | except DiffError: 121 | raise 122 | except Exception as e: 123 | raise UnexpectedError('{}: {}: {}'.format(file, e.__class__.__name__, 124 | e), e) 125 | 126 | 127 | def run_clang_format_diff(args, file): 128 | try: 129 | with io.open(file, 'r', encoding='utf-8') as f: 130 | original = f.readlines() 131 | except IOError as exc: 132 | raise DiffError(str(exc)) 133 | 134 | if args.in_place: 135 | invocation = [args.clang_format_executable, '-i', file] 136 | else: 137 | invocation = [args.clang_format_executable, file] 138 | 139 | if args.style: 140 | invocation.extend(['--style', args.style]) 141 | 142 | if args.dry_run: 143 | print(" ".join(invocation)) 144 | return [], [] 145 | 146 | # Use of utf-8 to decode the process output. 147 | # 148 | # Hopefully, this is the correct thing to do. 149 | # 150 | # It's done due to the following assumptions (which may be incorrect): 151 | # - clang-format will returns the bytes read from the files as-is, 152 | # without conversion, and it is already assumed that the files use utf-8. 153 | # - if the diagnostics were internationalized, they would use utf-8: 154 | # > Adding Translations to Clang 155 | # > 156 | # > Not possible yet! 157 | # > Diagnostic strings should be written in UTF-8, 158 | # > the client can translate to the relevant code page if needed. 159 | # > Each translation completely replaces the format string 160 | # > for the diagnostic. 161 | # > -- http://clang.llvm.org/docs/InternalsManual.html#internals-diag-translation 162 | # 163 | # It's not pretty, due to Python 2 & 3 compatibility. 164 | encoding_py3 = {} 165 | if sys.version_info[0] >= 3: 166 | encoding_py3['encoding'] = 'utf-8' 167 | 168 | try: 169 | proc = subprocess.Popen( 170 | invocation, 171 | stdout=subprocess.PIPE, 172 | stderr=subprocess.PIPE, 173 | universal_newlines=True, 174 | **encoding_py3) 175 | except OSError as exc: 176 | raise DiffError( 177 | "Command '{}' failed to start: {}".format( 178 | subprocess.list2cmdline(invocation), exc 179 | ) 180 | ) 181 | proc_stdout = proc.stdout 182 | proc_stderr = proc.stderr 183 | if sys.version_info[0] < 3: 184 | # make the pipes compatible with Python 3, 185 | # reading lines should output unicode 186 | encoding = 'utf-8' 187 | proc_stdout = codecs.getreader(encoding)(proc_stdout) 188 | proc_stderr = codecs.getreader(encoding)(proc_stderr) 189 | # hopefully the stderr pipe won't get full and block the process 190 | outs = list(proc_stdout.readlines()) 191 | errs = list(proc_stderr.readlines()) 192 | proc.wait() 193 | if proc.returncode: 194 | raise DiffError( 195 | "Command '{}' returned non-zero exit status {}".format( 196 | subprocess.list2cmdline(invocation), proc.returncode 197 | ), 198 | errs, 199 | ) 200 | if args.in_place: 201 | return [], errs 202 | return make_diff(file, original, outs), errs 203 | 204 | 205 | def bold_red(s): 206 | return '\x1b[1m\x1b[31m' + s + '\x1b[0m' 207 | 208 | 209 | def colorize(diff_lines): 210 | def bold(s): 211 | return '\x1b[1m' + s + '\x1b[0m' 212 | 213 | def cyan(s): 214 | return '\x1b[36m' + s + '\x1b[0m' 215 | 216 | def green(s): 217 | return '\x1b[32m' + s + '\x1b[0m' 218 | 219 | def red(s): 220 | return '\x1b[31m' + s + '\x1b[0m' 221 | 222 | for line in diff_lines: 223 | if line[:4] in ['--- ', '+++ ']: 224 | yield bold(line) 225 | elif line.startswith('@@ '): 226 | yield cyan(line) 227 | elif line.startswith('+'): 228 | yield green(line) 229 | elif line.startswith('-'): 230 | yield red(line) 231 | else: 232 | yield line 233 | 234 | 235 | def print_diff(diff_lines, use_color): 236 | if use_color: 237 | diff_lines = colorize(diff_lines) 238 | if sys.version_info[0] < 3: 239 | sys.stdout.writelines((l.encode('utf-8') for l in diff_lines)) 240 | else: 241 | sys.stdout.writelines(diff_lines) 242 | 243 | 244 | def print_trouble(prog, message, use_colors): 245 | error_text = 'error:' 246 | if use_colors: 247 | error_text = bold_red(error_text) 248 | print("{}: {} {}".format(prog, error_text, message), file=sys.stderr) 249 | 250 | 251 | def main(): 252 | parser = argparse.ArgumentParser(description=__doc__) 253 | parser.add_argument( 254 | '--clang-format-executable', 255 | metavar='EXECUTABLE', 256 | help='path to the clang-format executable', 257 | default='clang-format') 258 | parser.add_argument( 259 | '--extensions', 260 | help='comma separated list of file extensions (default: {})'.format( 261 | DEFAULT_EXTENSIONS), 262 | default=DEFAULT_EXTENSIONS) 263 | parser.add_argument( 264 | '-r', 265 | '--recursive', 266 | action='store_true', 267 | help='run recursively over directories') 268 | parser.add_argument( 269 | '-d', 270 | '--dry-run', 271 | action='store_true', 272 | help='just print the list of files') 273 | parser.add_argument( 274 | '-i', 275 | '--in-place', 276 | action='store_true', 277 | help='format file instead of printing differences') 278 | parser.add_argument('files', metavar='file', nargs='+') 279 | parser.add_argument( 280 | '-q', 281 | '--quiet', 282 | action='store_true', 283 | help="disable output, useful for the exit code") 284 | parser.add_argument( 285 | '-j', 286 | metavar='N', 287 | type=int, 288 | default=0, 289 | help='run N clang-format jobs in parallel' 290 | ' (default number of cpus + 1)') 291 | parser.add_argument( 292 | '--color', 293 | default='auto', 294 | choices=['auto', 'always', 'never'], 295 | help='show colored diff (default: auto)') 296 | parser.add_argument( 297 | '-e', 298 | '--exclude', 299 | metavar='PATTERN', 300 | action='append', 301 | default=[], 302 | help='exclude paths matching the given glob-like pattern(s)' 303 | ' from recursive search') 304 | parser.add_argument( 305 | '--style', 306 | help='formatting style to apply (LLVM, Google, Chromium, Mozilla, WebKit)') 307 | 308 | args = parser.parse_args() 309 | 310 | # use default signal handling, like diff return SIGINT value on ^C 311 | # https://bugs.python.org/issue14229#msg156446 312 | signal.signal(signal.SIGINT, signal.SIG_DFL) 313 | try: 314 | signal.SIGPIPE 315 | except AttributeError: 316 | # compatibility, SIGPIPE does not exist on Windows 317 | pass 318 | else: 319 | signal.signal(signal.SIGPIPE, signal.SIG_DFL) 320 | 321 | colored_stdout = False 322 | colored_stderr = False 323 | if args.color == 'always': 324 | colored_stdout = True 325 | colored_stderr = True 326 | elif args.color == 'auto': 327 | colored_stdout = sys.stdout.isatty() 328 | colored_stderr = sys.stderr.isatty() 329 | 330 | version_invocation = [args.clang_format_executable, str("--version")] 331 | try: 332 | subprocess.check_call(version_invocation, stdout=DEVNULL) 333 | except subprocess.CalledProcessError as e: 334 | print_trouble(parser.prog, str(e), use_colors=colored_stderr) 335 | return ExitStatus.TROUBLE 336 | except OSError as e: 337 | print_trouble( 338 | parser.prog, 339 | "Command '{}' failed to start: {}".format( 340 | subprocess.list2cmdline(version_invocation), e 341 | ), 342 | use_colors=colored_stderr, 343 | ) 344 | return ExitStatus.TROUBLE 345 | 346 | retcode = ExitStatus.SUCCESS 347 | 348 | excludes = excludes_from_file(DEFAULT_CLANG_FORMAT_IGNORE) 349 | excludes.extend(args.exclude) 350 | 351 | files = list_files( 352 | args.files, 353 | recursive=args.recursive, 354 | exclude=excludes, 355 | extensions=args.extensions.split(',')) 356 | 357 | if not files: 358 | return 359 | 360 | njobs = args.j 361 | if njobs == 0: 362 | njobs = multiprocessing.cpu_count() + 1 363 | njobs = min(len(files), njobs) 364 | 365 | if njobs == 1: 366 | # execute directly instead of in a pool, 367 | # less overhead, simpler stacktraces 368 | it = (run_clang_format_diff_wrapper(args, file) for file in files) 369 | pool = None 370 | else: 371 | pool = multiprocessing.Pool(njobs) 372 | it = pool.imap_unordered( 373 | partial(run_clang_format_diff_wrapper, args), files) 374 | pool.close() 375 | while True: 376 | try: 377 | outs, errs = next(it) 378 | except StopIteration: 379 | break 380 | except DiffError as e: 381 | print_trouble(parser.prog, str(e), use_colors=colored_stderr) 382 | retcode = ExitStatus.TROUBLE 383 | sys.stderr.writelines(e.errs) 384 | except UnexpectedError as e: 385 | print_trouble(parser.prog, str(e), use_colors=colored_stderr) 386 | sys.stderr.write(e.formatted_traceback) 387 | retcode = ExitStatus.TROUBLE 388 | # stop at the first unexpected error, 389 | # something could be very wrong, 390 | # don't process all files unnecessarily 391 | if pool: 392 | pool.terminate() 393 | break 394 | else: 395 | sys.stderr.writelines(errs) 396 | if outs == []: 397 | continue 398 | if not args.quiet: 399 | print_diff(outs, use_color=colored_stdout) 400 | if retcode == ExitStatus.SUCCESS: 401 | retcode = ExitStatus.DIFF 402 | if pool: 403 | pool.join() 404 | return retcode 405 | 406 | 407 | if __name__ == '__main__': 408 | sys.exit(main()) 409 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sarcasm/run-clang-format/39081c9c42768ab5e8321127a7494ad1647c6a2f/screenshot.png -------------------------------------------------------------------------------- /src/foo.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | std::string foo(const std::string &a, const std::string &b) { 4 | std::string sum; 5 | sum = a + b; 6 | return sum; 7 | } 8 | -------------------------------------------------------------------------------- /src/third_party/qux.cpp: -------------------------------------------------------------------------------- 1 | // This code is ignored by the .clang-format-ignore file. 2 | 3 | int qux (bool cond) { 4 | if(cond){ 5 | return - 1; 6 | } 7 | 8 | return 0; 9 | } 10 | --------------------------------------------------------------------------------