Repository: casey/just
Pull request #1001 (2021-10-14 10:34:54)

fmt: check formatting only in dry-run
Let's have a try for the 2nd part of #851. Instead of adding a `--check` flag, I reused `--dry-run` here as a PoC. However, `dry-run` conflicts with `-q` flag, thus the "well-formed" output always appear.

```sh
$ cargo -q run -- --unstable --dry-run --fmt
error: Justfile `B:\code\just\justfile` is not well-formatted.
$ echo $?
1

$ cargo -q run -- --unstable --fmt
Wrote justfile to `B:\code\just\justfile`
 $ cargo -q run -- --unstable --dry-run --fmt
Justfile `B:\code\just\justfile` is well-formatted.
```

For the next step, I don't have a good direction to do pretty diffing of the AST & file content yet.

Hope this helps. Please label "hacktoberfest"+"hacktoberfest-accepted" if you don't mind, thanks!

casey: > Ah sorry to bother again, but please add label "hacktoberfest-accepted" on PRs. The rules for this year distinguish "hacktoberfest" topic on projects, vs "hacktoberfest-accepted" I think. ![image](https://user-images.githubusercontent.com/12537/137475132-8993c31f-cda3-49a6-895e-7e0deef29205.png)

Done! Sorry, I must have been looking at outdated documentation.

> We appreciate your patience about this.

No problem at all!

hdhoang: I have incorporated your comments.

I also replaced `prettydiff` with `similar`, because it has 0-dep, and battle-tested in pijul. Plain output is also easier to copy into test macro. Though I still don't understand the double `\n\n` in expected stdout
hdhoang: I have made the shell completions. However I don't have `help2man` working, please regen it after merg, thanks
casey: Nice! The man page isn't checked in, so no need to generate it. Thanks for sticking with this!

Patch:
diff --git a/Cargo.lock b/Cargo.lock
index bed130eb..5204c551 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -231,6 +231,7 @@ dependencies = [
  "log",
  "pretty_assertions",
  "regex",
+ "similar",
  "snafu",
  "strum",
  "strum_macros",
@@ -463,6 +464,12 @@ version = "1.0.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "61b3909d758bb75c79f23d4736fac9433868679d3ad2ea7a61e3c25cfda9a088"
 
+[[package]]
+name = "similar"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e24979f63a11545f5f2c60141afe249d4f19f84581ea2138065e400941d83d3"
+
 [[package]]
 name = "snafu"
 version = "0.6.10"
diff --git a/Cargo.toml b/Cargo.toml
index 26cee9f2..9697940e 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -28,6 +28,7 @@ lexiclean = "0.0.1"
 libc = "0.2.0"
 log = "0.4.4"
 regex = "1.5.4"
+similar = "2.1.0"
 snafu = "0.6.0"
 strum_macros = "0.22.0"
 target = "2.0.0"
diff --git a/completions/just.bash b/completions/just.bash
index 7a1a2d27..cae30cc9 100644
--- a/completions/just.bash
+++ b/completions/just.bash
@@ -20,7 +20,7 @@ _just() {
 
     case "${cmd}" in
         just)
-            opts=" -q -u -v -e -l -h -V -f -d -c -s  --dry-run --highlight --no-dotenv --no-highlight --quiet --shell-command --clear-shell-args --unsorted --unstable --verbose --changelog --choose --dump --edit --evaluate --fmt --init --list --summary --variables --help --version --chooser --color --list-heading --list-prefix --justfile --set --shell --shell-arg --working-directory --command --completions --show --dotenv-filename --dotenv-path  <ARGUMENTS>... "
+            opts=" -q -u -v -e -l -h -V -f -d -c -s  --check --dry-run --highlight --no-dotenv --no-highlight --quiet --shell-command --clear-shell-args --unsorted --unstable --verbose --changelog --choose --dump --edit --evaluate --fmt --init --list --summary --variables --help --version --chooser --color --list-heading --list-prefix --justfile --set --shell --shell-arg --working-directory --command --completions --show --dotenv-filename --dotenv-path  <ARGUMENTS>... "
                 if [[ ${cur} == -* ]] ; then
                     COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
                     return 0
diff --git a/completions/just.elvish b/completions/just.elvish
index 4972a56e..531dcb40 100644
--- a/completions/just.elvish
+++ b/completions/just.elvish
@@ -32,6 +32,7 @@ edit:completion:arg-completer[just] = [@words]{
             cand --show 'Show information about <RECIPE>'
             cand --dotenv-filename 'Search for environment file named <DOTENV-FILENAME> instead of `.env`'
             cand --dotenv-path 'Load environment file at <DOTENV-PATH> instead of searching for one'
+            cand --check 'Run `--fmt` in ''check'' mode. Exits with 0 if justfile is formatted correctly. Exits with 1 and prints a diff if formatting is required.'
             cand --dry-run 'Print what just would do without doing it'
             cand --highlight 'Highlight echoed recipe lines in bold'
             cand --no-dotenv 'Don''t load `.env` file'
diff --git a/completions/just.fish b/completions/just.fish
index 5379aeaf..7a17f6c9 100644
--- a/completions/just.fish
+++ b/completions/just.fish
@@ -23,6 +23,7 @@ complete -c just -n "__fish_use_subcommand" -l completions -d 'Print shell compl
 complete -c just -n "__fish_use_subcommand" -s s -l show -d 'Show information about <RECIPE>'
 complete -c just -n "__fish_use_subcommand" -l dotenv-filename -d 'Search for environment file named <DOTENV-FILENAME> instead of `.env`'
 complete -c just -n "__fish_use_subcommand" -l dotenv-path -d 'Load environment file at <DOTENV-PATH> instead of searching for one'
+complete -c just -n "__fish_use_subcommand" -l check -d 'Run `--fmt` in \'check\' mode. Exits with 0 if justfile is formatted correctly. Exits with 1 and prints a diff if formatting is required.'
 complete -c just -n "__fish_use_subcommand" -l dry-run -d 'Print what just would do without doing it'
 complete -c just -n "__fish_use_subcommand" -l highlight -d 'Highlight echoed recipe lines in bold'
 complete -c just -n "__fish_use_subcommand" -l no-dotenv -d 'Don\'t load `.env` file'
diff --git a/completions/just.powershell b/completions/just.powershell
index f2be3127..f2f68d2d 100644
--- a/completions/just.powershell
+++ b/completions/just.powershell
@@ -37,6 +37,7 @@ Register-ArgumentCompleter -Native -CommandName 'just' -ScriptBlock {
             [CompletionResult]::new('--show', 'show', [CompletionResultType]::ParameterName, 'Show information about <RECIPE>')
             [CompletionResult]::new('--dotenv-filename', 'dotenv-filename', [CompletionResultType]::ParameterName, 'Search for environment file named <DOTENV-FILENAME> instead of `.env`')
             [CompletionResult]::new('--dotenv-path', 'dotenv-path', [CompletionResultType]::ParameterName, 'Load environment file at <DOTENV-PATH> instead of searching for one')
+            [CompletionResult]::new('--check', 'check', [CompletionResultType]::ParameterName, 'Run `--fmt` in ''check'' mode. Exits with 0 if justfile is formatted correctly. Exits with 1 and prints a diff if formatting is required.')
             [CompletionResult]::new('--dry-run', 'dry-run', [CompletionResultType]::ParameterName, 'Print what just would do without doing it')
             [CompletionResult]::new('--highlight', 'highlight', [CompletionResultType]::ParameterName, 'Highlight echoed recipe lines in bold')
             [CompletionResult]::new('--no-dotenv', 'no-dotenv', [CompletionResultType]::ParameterName, 'Don''t load `.env` file')
diff --git a/completions/just.zsh b/completions/just.zsh
index b504b709..1efbadce 100644
--- a/completions/just.zsh
+++ b/completions/just.zsh
@@ -33,6 +33,7 @@ _just() {
 '--show=[Show information about <RECIPE>]: :_just_commands' \
 '(--dotenv-path)--dotenv-filename=[Search for environment file named <DOTENV-FILENAME> instead of `.env`]' \
 '--dotenv-path=[Load environment file at <DOTENV-PATH> instead of searching for one]' \
+'--check[Run `--fmt` in '\''check'\'' mode. Exits with 0 if justfile is formatted correctly. Exits with 1 and prints a diff if formatting is required.]' \
 '(-q --quiet)--dry-run[Print what just would do without doing it]' \
 '--highlight[Highlight echoed recipe lines in bold]' \
 '--no-dotenv[Don'\''t load `.env` file]' \
diff --git a/src/config.rs b/src/config.rs
index e8a1f1c1..bed6e1e2 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -15,6 +15,7 @@ pub(crate) const DEFAULT_SHELL_ARG: &str = "-cu";
 #[derive(Debug, PartialEq)]
 pub(crate) struct Config {
   pub(crate) color: Color,
+  pub(crate) check: bool,
   pub(crate) dry_run: bool,
   pub(crate) highlight: bool,
   pub(crate) invocation_directory: PathBuf,
@@ -81,6 +82,7 @@ mod cmd {
 
 mod arg {
   pub(crate) const ARGUMENTS: &str = "ARGUMENTS";
+  pub(crate) const CHECK: &str = "CHECK";
   pub(crate) const CHOOSER: &str = "CHOOSER";
   pub(crate) const CLEAR_SHELL_ARGS: &str = "CLEAR-SHELL-ARGS";
   pub(crate) const COLOR: &str = "COLOR";
@@ -116,6 +118,12 @@ impl Config {
       .version_message("Print version information")
       .setting(AppSettings::ColoredHelp)
       .setting(AppSettings::TrailingVarArg)
+      .arg(
+        Arg::with_name(arg::CHECK)
+          .long("check")
+          .requires(cmd::FORMAT)
+          .help("Run `--fmt` in 'check' mode. Exits with 0 if justfile is formatted correctly. Exits with 1 and prints a diff if formatting is required."),
+      )
       .arg(
         Arg::with_name(arg::CHOOSER)
           .long("chooser")
@@ -532,6 +540,7 @@ impl Config {
       || matches.occurrences_of(arg::SHELL_ARG) > 0;
 
     Ok(Self {
+      check: matches.is_present(arg::CHECK),
       dry_run: matches.is_present(arg::DRY_RUN),
       highlight: !matches.is_present(arg::NO_HIGHLIGHT),
       shell: matches.value_of(arg::SHELL).unwrap().to_owned(),
@@ -594,6 +603,9 @@ USAGE:
 
 FLAGS:
         --changelog           Print changelog
+        --check               Run `--fmt` in 'check' mode. Exits with 0 if
+                              justfile is formatted correctly. Exits with 1 and
+                              prints a diff if formatting is required.
         --choose              Select one or more recipes to run using a binary.
                               If `--chooser` is not passed the chooser defaults
                               to the value of $JUST_CHOOSER, falling back to
diff --git a/src/error.rs b/src/error.rs
index be044f00..b479570d 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -75,6 +75,7 @@ pub(crate) enum Error<'src> {
     variable: String,
     suggestion: Option<Suggestion<'src>>,
   },
+  FormatCheckFoundDiff,
   FunctionCall {
     function: Name<'src>,
     message: String,
@@ -458,6 +459,9 @@ impl<'src> ColorDisplay for Error<'src> {
           write!(f, "\n{}", suggestion)?;
         }
       }
+      FormatCheckFoundDiff => {
+        write!(f, "Formatted justfile differs from original.")?;
+      }
       FunctionCall { function, message } => {
         write!(
           f,
diff --git a/src/subcommand.rs b/src/subcommand.rs
index b6e81e3f..3dc0d6d4 100644
--- a/src/subcommand.rs
+++ b/src/subcommand.rs
@@ -77,7 +77,7 @@ impl Subcommand {
         justfile.run(config, &search, overrides, &[])?
       }
       Dump => Self::dump(ast),
-      Format => Self::format(config, ast, &search)?,
+      Format => Self::format(config, ast, &src, &search)?,
       List => Self::list(config, justfile),
       Run {
         arguments,
@@ -255,11 +255,22 @@ impl Subcommand {
     Ok(())
   }
 
-  fn format(config: &Config, ast: Ast, search: &Search) -> Result<(), Error<'static>> {
+  fn format(config: &Config, ast: Ast, src: &str, search: &Search) -> Result<(), Error<'static>> {
     config.require_unstable("The `--fmt` command is currently unstable.")?;
 
-    if let Err(io_error) =
-      File::create(&search.justfile).and_then(|mut file| write!(file, "{}", ast))
+    let src_formatted = ast.to_string();
+    if config.check {
+      if src == src_formatted {
+        Ok(())
+      } else {
+        print!(
+          "{}",
+          similar::udiff::unified_diff(similar::Algorithm::Patience, &src, &src_formatted, 2, None)
+        );
+        Err(Error::FormatCheckFoundDiff)
+      }
+    } else if let Err(io_error) =
+      File::create(&search.justfile).and_then(|mut file| file.write_all(&src_formatted.as_bytes()))
     {
       Err(Error::WriteJustfile {
         justfile: search.justfile.clone(),


Test patch:
diff --git a/tests/fmt.rs b/tests/fmt.rs
index 7e7e4b16..16530967 100644
--- a/tests/fmt.rs
+++ b/tests/fmt.rs
@@ -11,6 +11,51 @@ test! {
   status: EXIT_FAILURE,
 }
 
+test! {
+  name: check_without_fmt,
+  justfile: "",
+  args: ("--check"),
+  stderr_regex: "error: The following required arguments were not provided:
+    --fmt
+(.|\\n)+",
+  status: EXIT_FAILURE,
+}
+
+test! {
+  name: dry_run_ok,
+  justfile: r#"
+# comment   with   spaces
+
+export x := `backtick
+with
+lines`
+
+recipe: deps
+    echo "$x"
+
+deps:
+    echo {{ x }}
+    echo '$x'
+"#,
+  args: ("--unstable", "--fmt", "--check"),
+  status: EXIT_SUCCESS,
+}
+
+test! {
+  name: dry_run_found_diff,
+  justfile: "x:=``\n",
+  args: ("--unstable", "--fmt", "--check"),
+  stdout: "
+  @@ -1 +1 @@
+  -x:=``
+  +x := ``
+  ",
+  stderr: "
+    error: Formatted justfile differs from original.
+  ",
+  status: EXIT_FAILURE,
+}
+
 #[test]
 fn unstable_passed() {
   let tmp = tempdir();