1 /** 2 Copyright: Copyright (c) 2017, Joakim Brännström. All rights reserved. 3 License: $(LINK2 http://www.boost.org/LICENSE_1_0.txt, Boost Software License 1.0) 4 Author: Joakim Brännström (joakim.brannstrom@gmx.com) 5 */ 6 module autoformat.app; 7 8 import logger = std.experimental.logger; 9 import std.algorithm; 10 import std.conv : text; 11 import std.exception; 12 import std.file; 13 import std.format; 14 import std.getopt; 15 import std.parallelism; 16 import std.path; 17 import std.process; 18 import std.range; 19 import std.regex : matchFirst, ctRegex; 20 import std.stdio; 21 import std.typecons; 22 import std.variant; 23 24 import colorlog; 25 import my.optional; 26 import sumtype; 27 28 import autoformat.formatter_tools; 29 import autoformat.git; 30 import autoformat.types; 31 32 immutable hookPreCommit = import("pre_commit"); 33 immutable hookPrepareCommitMsg = import("prepare_commit_msg"); 34 immutable gitConfigKey = "hooks.autoformat"; 35 36 /// Active modes depending on the flags passed by the user. 37 enum Mode { 38 /// Normal mode which is one or more files from command line 39 normal, 40 /// Print help and exit 41 helpAndExit, 42 /// Create the symlinks to emulate the old autoformatter written in python 43 setup, 44 /// Install git hooks 45 installGitHook, 46 /// Check staged files for trailing whitespace 47 checkGitTrailingWhitespace, 48 } 49 50 /// The mode used to collect the files to process. 51 enum FileMode { 52 /// Normal mode which is one or more files from command line 53 normal, 54 /// File list from stdin but processed as normal 55 normalFileListFromStdin, 56 /// Process recursively 57 recursive, 58 } 59 60 /// The procedure to use to process files. 61 enum ToolMode { 62 /// Normal mode which is one or more files from command line 63 normal, 64 /// Whitespace checker and fixup 65 detabTool, 66 } 67 68 struct Config { 69 VerboseMode verbosity; 70 Flag!"dryRun" dryRun; 71 string installHook; 72 Flag!"backup" backup; 73 74 string[] rawFiles; 75 76 Mode mode; 77 FileMode fileMode; 78 ToolMode formatMode; 79 } 80 81 int main(string[] args) nothrow { 82 confLogger(VerboseMode.info).collectException; 83 84 Config conf; 85 GetoptResult help_info; 86 87 parseArgs(args, conf, help_info); 88 confLogger(conf.verbosity).collectException; 89 90 final switch (conf.mode) { 91 case Mode.helpAndExit: 92 printHelp(args[0], help_info); 93 return -1; 94 case Mode.setup: 95 try { 96 return setup(args); 97 } catch (Exception ex) { 98 logger.error("Unable to perform the setup").collectException; 99 logger.error(ex.msg).collectException; 100 return 1; 101 } 102 case Mode.installGitHook: 103 import std.file : thisExePath; 104 105 string path_to_binary; 106 try { 107 path_to_binary = thisExePath; 108 } catch (Exception ex) { 109 logger.error("Unable to read the symlink '/proc/self/exe'. Using args[0] instead, " ~ args[0]) 110 .collectException; 111 path_to_binary = args[0]; 112 } 113 try { 114 return installGitHook(AbsolutePath(conf.installHook), path_to_binary); 115 } catch (Exception ex) { 116 logger.error("Unable to install the git hook").collectException; 117 logger.error(ex.msg).collectException; 118 return 1; 119 } 120 case Mode.checkGitTrailingWhitespace: 121 import autoformat.tool_whitespace_check; 122 123 int returnCode; 124 runWhitespaceCheck.match!((Unchanged a) {}, (FormattedOk a) {}, (WouldChange a) { 125 }, (FailedWithUserMsg a) { 126 logger.error(a.msg).collectException; 127 returnCode = 1; 128 }, (FormatError a) { returnCode = 1; }); 129 return returnCode; 130 case Mode.normal: 131 return fileMode(conf); 132 } 133 } 134 135 int fileMode(Config conf) nothrow { 136 AbsolutePath[] files; 137 138 final switch (conf.fileMode) { 139 case FileMode.recursive: 140 try { 141 auto tmp = recursiveFileList(AbsolutePath(conf.rawFiles[0])); 142 if (tmp.isNull) 143 return 1; 144 else 145 files = tmp.get; 146 } catch (Exception ex) { 147 logger.error("Error during recursive processing of files").collectException; 148 logger.error(ex.msg).collectException; 149 return 1; 150 } 151 break; 152 case FileMode.normalFileListFromStdin: 153 try { 154 files = filesFromStdin; 155 } catch (Exception ex) { 156 logger.error("Unable to read a list of files separated by newline from stdin") 157 .collectException; 158 logger.error(ex.msg).collectException; 159 return 1; 160 } 161 break; 162 case FileMode.normal: 163 try { 164 files = conf.rawFiles.map!(a => AbsolutePath(a)).array(); 165 } catch (Exception e) { 166 logger.error(e.msg).collectException; 167 return 1; 168 } 169 break; 170 } 171 172 return formatMode(conf, files); 173 } 174 175 int formatMode(Config conf, AbsolutePath[] files) nothrow { 176 import std.conv : to; 177 import std.typecons : tuple; 178 179 const auto tconf = ToolConf(conf.dryRun, conf.backup); 180 const auto pconf = conf.verbosity == VerboseMode.trace ? PoolConf.debug_ : PoolConf.auto_; 181 FormatterResult result; 182 183 final switch (conf.formatMode) { 184 case ToolMode.normal: 185 try { 186 result = parallelRun!(oneFileRespectKind, OneFileConf)(files, pconf, tconf); 187 } catch (Exception ex) { 188 logger.error("Failed to run").collectException; 189 logger.error(ex.msg).collectException; 190 } 191 break; 192 193 case ToolMode.detabTool: 194 import autoformat.tool_detab; 195 196 static auto runDetab(OneFileConf f) nothrow { 197 try { 198 if (f.value.isDir) { 199 return FormatterResult(Unchanged.init); 200 } 201 } catch (Exception ex) { 202 return FormatterResult(Unchanged.init); 203 } 204 205 static import autoformat.tool_detab; 206 207 return autoformat.tool_detab.runDetab(f.value, f.conf.backup, f.conf.dryRun); 208 } 209 210 try { 211 result = parallelRun!(runDetab, OneFileConf)(files, pconf, tconf); 212 } catch (Exception ex) { 213 logger.error("Failed to run").collectException; 214 logger.error(ex.msg).collectException; 215 } 216 break; 217 } 218 219 int returnCode; 220 if (conf.dryRun) { 221 result.match!((Unchanged a) {}, (FormattedOk a) { returnCode = 1; }, (WouldChange a) { 222 returnCode = 1; 223 }, (FailedWithUserMsg a) {}, (FormatError a) {}); 224 } else { 225 result.match!((Unchanged a) {}, (FormattedOk a) {}, (WouldChange a) {}, 226 (FailedWithUserMsg a) {}, (FormatError a) { returnCode = 1; }); 227 } 228 229 return returnCode; 230 } 231 232 void parseArgs(ref string[] args, ref Config conf, ref GetoptResult help_info) nothrow { 233 import std.traits : EnumMembers; 234 235 bool check_whitespace; 236 bool dryRun; 237 bool help; 238 bool noBackup; 239 bool recursive; 240 bool setup; 241 bool stdin_; 242 bool tool_detab; 243 244 try { 245 // dfmt off 246 help_info = getopt(args, std.getopt.config.keepEndOfOptions, 247 "check-trailing-whitespace", "check files for trailing whitespace", &check_whitespace, 248 "i|install-hook", "install git hooks to autoformat during commit of added or modified files", &conf.installHook, 249 "no-backup", "no backup file is created", &noBackup, 250 "n|dry-run", "(ONLY supported by c, c++, java) perform a trial run with no changes made to the files. Exit status != 0 indicates a change would have occured if ran without --dry-run", &dryRun, 251 "r|recursive", "autoformat recursive", &recursive, 252 "setup", "finalize installation of autoformatter by creating symlinks", &setup, 253 "stdin", "file list separated by newline read from", &stdin_, 254 "tool-detab", "whitespace checker and fixup (all filetypes, respects .noautoformat)", &tool_detab, 255 "v|verbose", format("Set the verbosity (%-(%s, %))", [EnumMembers!(VerboseMode)]), &conf.verbosity, 256 ); 257 // dfmt on 258 conf.dryRun = cast(typeof(Config.dryRun)) dryRun; 259 conf.backup = cast(typeof(Config.backup)) !noBackup; 260 help = help_info.helpWanted; 261 } catch (std.getopt.GetOptException ex) { 262 logger.error(ex.msg).collectException; 263 help = true; 264 } catch (Exception ex) { 265 logger.error(ex.msg).collectException; 266 help = true; 267 } 268 269 // Main mode 270 271 if (help) { 272 conf.mode = Mode.helpAndExit; 273 } else if (setup) { 274 conf.mode = Mode.setup; 275 } else if (conf.installHook.length != 0) { 276 conf.mode = Mode.installGitHook; 277 } else if (check_whitespace) { 278 conf.mode = Mode.checkGitTrailingWhitespace; 279 } 280 281 if (conf.mode != Mode.normal) { 282 // modes that do not require a specific FileMode 283 return; 284 } 285 286 // File mode 287 288 if (recursive) { 289 conf.fileMode = FileMode.recursive; 290 } else if (stdin_) { 291 conf.fileMode = FileMode.normalFileListFromStdin; 292 } 293 294 if (args.length > 1) 295 conf.rawFiles = args[1 .. $]; 296 297 if (conf.fileMode != FileMode.normalFileListFromStdin && args.length < 2) { 298 logger.error("Wrong number of arguments, probably missing FILE(s)").collectException; 299 conf.mode = Mode.helpAndExit; 300 return; 301 } 302 303 // Tool mode 304 305 if (tool_detab) { 306 conf.formatMode = ToolMode.detabTool; 307 } 308 } 309 310 AbsolutePath[] filesFromStdin() { 311 import std.string : strip; 312 313 auto r = appender!(AbsolutePath[])(); 314 315 char[] line; 316 while (stdin.readln(line)) { 317 r.put(AbsolutePath(line.strip.idup)); 318 } 319 320 return r.data; 321 } 322 323 struct OneFileConf { 324 Tuple!(ulong, "index", AbsolutePath, "value") f; 325 alias f this; 326 327 ToolConf conf; 328 } 329 330 alias ToolConf = Tuple!(Flag!"dryRun", "dryRun", Flag!"backup", "backup"); 331 332 FormatterResult oneFileRespectKind(OneFileConf f) nothrow { 333 try { 334 if (f.value.isDir || f.value.extension.length == 0) { 335 return FormatterResult(Unchanged.init); 336 } 337 } catch (Exception ex) { 338 return FormatterResult(Unchanged.init); 339 } 340 341 auto res = isOkToFormat(f.value); 342 if (!res.ok) { 343 try { 344 logger.warningf("%s %s", f.index + 1, res.payload); 345 } catch (Exception ex) { 346 logger.error(ex.msg).collectException; 347 } 348 return FormatterResult(Unchanged.init); 349 } 350 351 auto rval = FormatterResult(Unchanged.init); 352 353 try { 354 rval = formatFile(AbsolutePath(f.value), f.conf.backup, f.conf.dryRun); 355 } catch (Exception ex) { 356 logger.error(ex.msg).collectException; 357 } 358 359 return rval; 360 } 361 362 enum PoolConf { 363 debug_, 364 auto_ 365 } 366 367 FormatterResult parallelRun(alias Func, ArgsT)(AbsolutePath[] files_, PoolConf poolc, ToolConf conf) { 368 static FormatterResult merge(FormatterResult a, FormatterResult b) { 369 auto rval = a; 370 371 // if a is an error then let it propagate 372 a.match!((Unchanged a) { rval = b; }, (FormattedOk a) { rval = b; }, (WouldChange a) { 373 }, (FailedWithUserMsg a) {}, (FormatError a) {}); 374 375 // if b is unchanged then let the previous value propagate 376 b.match!((Unchanged a) { rval = a; }, (FormattedOk a) {}, (WouldChange a) { 377 }, (FailedWithUserMsg a) {}, (FormatError a) {}); 378 379 return rval; 380 } 381 382 // dfmt off 383 auto files = files_ 384 .filter!(a => a.length > 0) 385 .enumerate.map!(a => ArgsT(a, conf)) 386 .array(); 387 // dfmt on 388 389 TaskPool pool; 390 final switch (poolc) { 391 case PoolConf.debug_: 392 // zero because the main thread is also working which thus ensures that 393 // only one thread in the pool exist for work. No parallelism. 394 pool = new TaskPool(0); 395 break; 396 case PoolConf.auto_: 397 pool = new TaskPool; 398 break; 399 } 400 401 scope (exit) 402 pool.stop; 403 auto status = pool.reduce!merge(FormatterResult(Unchanged.init), 404 std.algorithm.map!Func(files)); 405 pool.finish; 406 407 return status; 408 } 409 410 Nullable!(AbsolutePath[]) recursiveFileList(AbsolutePath path) { 411 typeof(return) rval; 412 413 if (!path.isDir) { 414 logger.errorf("not a directory: %s", path); 415 return rval; 416 } 417 418 rval = dirEntries(path, std.file.SpanMode.depth).map!(a => AbsolutePath(a.name)).array(); 419 return rval; 420 } 421 422 FormatterResult formatFile(AbsolutePath p, Flag!"backup" backup, Flag!"dryRun" dry_run) nothrow { 423 FormatterResult status; 424 425 try { 426 logger.tracef("%s (backup:%s dryRun:%s)", p, backup, dry_run); 427 428 foreach (f; formatters) { 429 if (f[0](p.extension)) { 430 auto res = f[1](p, backup, dry_run); 431 status = res; 432 433 res.match!((Unchanged a) {}, (FormattedOk a) { 434 logger.info("formatted ", p); 435 }, (WouldChange a) { logger.info("formatted (dryrun) ", p); }, (FailedWithUserMsg a) { 436 logger.error(a.msg); 437 }, (FormatError a) { logger.error("unable to format ", p); }); 438 break; 439 } 440 } 441 } catch (Exception ex) { 442 logger.error("Unable to format file: " ~ p).collectException; 443 logger.error(ex.msg).collectException; 444 } 445 446 return status; 447 } 448 449 void printHelp(string arg0, ref GetoptResult help_info) nothrow { 450 import std.format : format; 451 452 try { 453 defaultGetoptPrinter(format(`Tool to format [c, c++, java, d, rust] source code 454 Usage: %s [options] PATH`, 455 arg0), help_info.options); 456 } catch (Exception ex) { 457 logger.error("Unable to print command line interface help information to stdout") 458 .collectException; 459 logger.error(ex.msg).collectException; 460 } 461 } 462 463 int setup(string[] args) { 464 immutable arg0 = args[0]; 465 immutable original = arg0.expandTilde.absolutePath; 466 immutable base = original.dirName; 467 immutable backward_compatible_py0 = buildPath(base, "autoformat_src.py"); 468 immutable backward_compatible_py1 = buildPath(base, "autoformat_src"); 469 470 foreach (p; [backward_compatible_py0, backward_compatible_py1].filter!(a => !exists(a))) { 471 symlink(original, p); 472 } 473 474 return 0; 475 } 476 477 /** 478 * Params: 479 * install_to = path to a director containing a .git-directory 480 * autoformat_bin = either an absolute or relative path to the autoformat binary 481 */ 482 int installGitHook(AbsolutePath install_to, string autoformat_bin) { 483 static void usage() { 484 if (gitConfigValue(gitConfigKey).orElse(string.init).among("auto", "warn", "interrupt")) { 485 return; 486 } 487 488 writeln("Activate hooks by configuring git"); 489 writeln(" # autoformat all changed files during commit"); 490 writeln(" git config --global hooks.autoformat auto"); 491 writeln(" # Warn if a file doesn't follow code standard during commit"); 492 writeln(" git config --global hooks.autoformat warn"); 493 writeln(" # Interrupt commit if a file doesn't follow code standard during commit"); 494 writeln(" git config --global hooks.autoformat interrupt"); 495 writeln; 496 writeln(" # check for trailing whitespace in all staged files"); 497 writeln(" # this can be used separately from the above autoformat"); 498 writeln(" git config --global hooks.autoformat-check-whitespace true"); 499 writeln; 500 writeln("Recommendation:"); 501 writeln(" git config --global hooks.autoformat auto"); 502 writeln(" git config --global hooks.autoformat-check-whitespace true"); 503 } 504 505 static void createHook(AbsolutePath hook_p, string msg) { 506 auto f = File(hook_p, "w"); 507 f.write(msg); 508 f.close; 509 makeExecutable(hook_p); 510 } 511 512 static void injectHook(AbsolutePath p, string raw) { 513 import std.utf; 514 515 // remove the old hook so it doesn't collide. 516 // v1: This will probably have to stay until late 2019. 517 string v1 = format("source $GIT_DIR/hooks/%s", raw); 518 // v2: Stay until late 2020 519 string v2 = format("$GIT_DIR/hooks/%s $@", raw); 520 string latest = format("$(git rev-parse --git-dir)/hooks/%s $@", raw); 521 522 if (exists(p)) { 523 auto content = File(p).byLine.appendUnique(latest, [v1, v2, latest]).joiner("\n").text; 524 auto f = File(p, "w"); 525 f.writeln(content); 526 } else { 527 auto f = File(p, "w"); 528 f.writeln("#!/bin/bash"); 529 f.writeln(latest); 530 f.close; 531 } 532 makeExecutable(p); 533 } 534 535 { // sanity check 536 if (!exists(install_to)) { 537 writefln("Unable to install to %s, it doesn't exist", install_to); 538 return 1; 539 } else if (!isGitRoot(install_to)) { 540 writefln("%s is not a git repo (no .git directory found)", install_to); 541 } 542 } 543 544 AbsolutePath hook_dir; 545 { 546 auto p = gitHookPath(install_to); 547 if (p.hasValue) { 548 hook_dir = p.orElse(AbsolutePath.init); 549 } else { 550 logger.error("Unable to locate a git hook directory at: ", install_to); 551 return 1; 552 } 553 } 554 555 import autoformat.format_c_cpp : clangToolEnvKey, getClangFormatterTool; 556 557 auto git_pre_commit = buildPath(hook_dir, "pre-commit"); 558 auto git_pre_msg = buildPath(hook_dir, "prepare-commit-msg"); 559 auto git_auto_pre_commit = buildPath(hook_dir, "autoformat_pre-commit"); 560 auto git_auto_pre_msg = buildPath(hook_dir, "autoformat_prepare-commit-msg"); 561 logger.info("Installing git hooks to: ", install_to); 562 createHook(AbsolutePath(git_auto_pre_commit), format(hookPreCommit, 563 autoformat_bin, clangToolEnvKey, getClangFormatterTool)); 564 createHook(AbsolutePath(git_auto_pre_msg), format(hookPrepareCommitMsg, autoformat_bin)); 565 injectHook(AbsolutePath(git_pre_commit), git_auto_pre_commit.baseName); 566 injectHook(AbsolutePath(git_pre_msg), git_auto_pre_msg.baseName); 567 568 usage; 569 570 return 0; 571 } 572 573 /// Append the string to the range if it doesn't exist. 574 auto appendUnique(T)(T r, string msg, string[] remove) if (isInputRange!T) { 575 enum State { 576 analyzing, 577 found, 578 append, 579 finished 580 } 581 582 struct Result { 583 string msg; 584 string[] remove; 585 T r; 586 State st; 587 588 string front() { 589 assert(!empty, "Can't get front of an empty range"); 590 591 if (st == State.append) { 592 return msg; 593 } 594 595 static if (is(typeof(r.front) == char[])) { 596 return r.front.idup; 597 } else { 598 return r.front; 599 } 600 } 601 602 void popFront() { 603 assert(!empty, "Can't pop front of an empty range"); 604 if (st == State.analyzing) { 605 r.popFront; 606 if (r.empty) { 607 st = State.append; 608 } else if (canFind(remove, r.front)) { 609 popFront; 610 } else if (r.front == msg) { 611 st = State.found; 612 } 613 } else if (st == State.found) { 614 r.popFront; 615 } else if (st == State.append) { 616 st = State.finished; 617 } 618 } 619 620 bool empty() { 621 if (st.among(State.analyzing, State.found)) { 622 return r.empty; 623 } else if (st == State.append) { 624 return false; 625 } else { 626 return true; 627 } 628 } 629 } 630 631 return Result(msg, remove, r); 632 } 633 634 @("shall append the message if it doesn't exist") 635 unittest { 636 string msg = "append me"; 637 string remove = "remove me"; 638 639 string[] text_with_msg = "foo\nbar\nappend me\nfjump\nremove me\n".split("\n"); 640 string[] text_missing_msg = "foo\nremove me\nbar\nfjump\n".split("\n"); 641 642 { 643 string[] result = text_with_msg.appendUnique(msg, remove).array(); 644 writeln(text_with_msg, result); 645 assert(cmp(result, text_with_msg) == 0); 646 } 647 { 648 string[] result = text_missing_msg.appendUnique(msg, remove).array(); 649 writeln(text_missing_msg, result); 650 assert(cmp(result, text_missing_msg ~ [msg]) == 0); 651 } 652 } 653 654 void makeExecutable(string path) { 655 import core.sys.posix.sys.stat; 656 657 setAttributes(path, getAttributes(path) | S_IRWXU); 658 }