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 }