Repository: casey/just
Pull request #837 (2021-05-23 06:23:50)

Add `--fmt` subcommand
Implements #817.

Things I'd like to get feedback so I could fix them:

- copypasted `impl<'src> Display for UnresolvedRecipe<'src>` (cannot wrestle borrow checker)
- ugly `tree: (justfile ())` instead of `tree: (justfile)` to pass tests (not sure that matters)
- justfile in repository uses tabs but `--fmt` formats them to spaces, probably because it reuses space-based impl originally used for `--dump` (I don't have any preference on the matter)

casey: Thanks for taking a crack at this:

> * copypasted `impl<'src> Display for UnresolvedRecipe<'src>` (cannot wrestle borrow checker)

What was the issue that you ran into? I think you could bound `D` in Recipe by `Display`:

```rust
pub(crate) struct Recipe<'src, D: Display = Dependency<'src>> {
```

You'll also need to add it to the impl blocks:

```rust
impl<'src, D: Display> Recipe<'src, D> {
```

> * ugly `tree: (justfile ())` instead of `tree: (justfile)` to pass tests (not sure that matters)

If there are no comments, shouldn't that avoid an empty `()`?

> * justfile in repository uses tabs but `--fmt` formats them to spaces, probably because it reuses space-based impl originally used for `--dump` (I don't have any preference on the matter)

I'm not sure what `--fmt` should use by default. I use tabs, because for a long time I used make-based syntax highlighting in vim, which highlights space-indentation as an error, since makefiles only use tabs.

I used to dislike tabs, but have actually come around to them, since tabs let everyone use a different tab-width. I'm inclined to keep them, and change `--dump` to use tabs.
casey: Actually, for now, let's use spaces, just to keep the PR-size reasonable. We can adjust `--fmt` defaults and configuration going forward.
vglfr: Fixed it, thanks! It turns out `impl<'src> Display for Recipe<'src>` needs to be replaced by `impl<'src, D: Display> Display for Recipe<'src, D>`.

As for `(justfile ())` it's probably false alarm. In that particular case `justfile.tree()` is called on `# foo`. Can't see any harm done, it's just untidy.
casey: Let me know when this is ready to review. The main thing I'd like to see are a bunch of tests, probably for every construct, that the output of `--fmt` is reasonable, and also a valid justfile. I think the output of `--dump` should be the same as `--fmt`, so the tests can call `--dump` and check the output. See the tests in `tests/misc.rs` for some examples. The new tests can go in `tests/fmt.rs`.
vglfr: I think it's ready. Few random notes before review:
- `--fmt` and `--dump` are the same for recipes, assignments, and aliases; `--fmt` additionally supports sets and comments
- `--fmt` removes extra newlines between two aliases, sets, assignments, or comments, thus implementing basic grouping
- conditional inside recipe changed from `{{if ... { } else { } }}` to `{{ if ... { } else { } }}` while dropping trailing whitespace
- indented backticks keep their triple quotes (previously were formatted to single quote)
casey: Awesome! I just ran CI, and I think the completion scripts are out of date.

> * `--fmt` and `--dump` are the same for recipes, assignments, and aliases; `--fmt` additionally supports sets and comments

I think that `--dump` and `--fmt` should produce the same output, so I would leave comments in `--dump`. I looked at the code, but it wasn't obvious to me why sets would be different between the two. Are they handled differently?

> * `--fmt` removes extra newlines between two aliases, sets, assignments, or comments, thus implementing basic grouping

Sounds good!

> * conditional inside recipe changed from `{{if ... { } else { } }}` to `{{ if ... { } else { } }}` while dropping trailing whitespace

Also good, it definitely looks better this way.

> * indented backticks keep their triple quotes (previously were formatted to single quote)

Nice. We might eventually want to make `--fmt` convert indented backticks to some standard formatting, but this is great for now.
vglfr: Updated completions.

>I think that --dump and --fmt should produce the same output, so I would leave comments in --dump. I looked at the code, but it wasn't obvious to me why sets would be different between the two. Are they handled differently?

Yes, `--dump` isn't printing sets at all. I think that's original behavior. Adding sets to `--dump` should be straightforward. However it might be impossible to determine from `Justfile` struct whether setting was set explicitly (when it is default). So either all settings or non-default settings could be dumped. `--fmt` doesn't have this dilemma, it just formats all sets as they appear in justfile.

Recipe docstring comments are displayed in `--dump` all right, as well as comments in recipe body. However non-recipe-docstring top-level comments are skipped (including essential ones like first line shebang). Adding them to `--dump` would require modifying `Justfile` struct.

Please let me know what's the right way to handle those.
casey: Ah, right. That makes sense. I think we should change it so that `--dump` prints out the `Module`, like `--fmt`. I don't think anyone is relying on comments being stripped from `--dump`, and making them do the same thing would allow users to, for example, redirect the output of `--dump` to a new justfile and have it be valid.
vglfr: Updated. Redirecting `--dump` sounds cool.
casey: Awesome! Just pushed a commit fixing a small formatting diff. I'll check this out today or tomorrow.
vglfr: Fixed. Thanks for a review!
casey: I think that adding `dotenv-load := true` to tests was actually just a temporary hack to make refactoring easier. I removed it in #853, so I think all the special case stuff around it can be removed. Sorry for the churn! I should have removed it long ago.
vglfr: Fixed, thanks!
casey: Hooookay!

Sorry for the delay in reviewing this. I was in Miami for the Bitcoin conference. (Which was like a Bitcoin-themed circus, a bit superficial, but lots of fun.)

I changed the help message text a little bit, refactored the format subcommand to make `Display` write directly to disk instead of creating a temporary, and refactored the doc comment handling a little bit.

My concern with the doc comment handling was that doc comment might get "lost", i.e. never actually turned into an item. I changed it so that comments are always inserted into the `items` vector, and popped off and passed into each call of `parse_recipe`. (Only if `eol_since_last_comment` is false, which is set to true whenever the parser encounters a newline.)

I was going to say that this is ready to merge, but I thought of something looking over the code one last time. Comments that are on the same line as items aren't preserved. For example, this comment won't be preserved as an `Item`.

```
alias x := y # foo
```

Since this PR is already pretty large, I was thinking about merging this, and then adding an `--unstable` flag. Unless `--unstable` was passed, `--fmt` would return an error. My thinking is that it's okay if the output of `--dump` is missing things, but since `--fmt` overwrites the justfile, there's a higher bar for preserving everything in the justfile.

What do you think?
casey: Just thinking out loud:

The method that swallows comments is `expect_eol`. I'm thinking that we should move the `items` vec into the `Parser` struct, and then have individual parsing functions push items into the vec, instead of returning items. This would allow methods like `expect_eol` to insert comments. I'm not sure the best way preserve the position of comments (whether they appear on lines by themselves or after items). We might have to create an `Item::Eol` that's created for each newline.
casey: > Hi, thanks for fixes, they're really instructive (which is what I'm looking for - besides the fact that I'm using Just in all my projects and would love to pay back).

You bet!

> Same line comments is something I've overlooked. Totally fine with `--unstable`.

Awesome, I'll merge this now, and add an `--unstable` flag before the next release.

Patch:
diff --git a/completions/just.bash b/completions/just.bash
index 4de4bcfb..1e152b89 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 --verbose --choose --dump --edit --evaluate --init --list --summary --variables --help --version --chooser --color --list-heading --list-prefix --justfile --set --shell --shell-arg --working-directory --command --completions --show  <ARGUMENTS>... "
+            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 --verbose --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  <ARGUMENTS>... "
                 if [[ ${cur} == -* ]] ; then
                     COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
                     return 0
diff --git a/completions/just.elvish b/completions/just.elvish
index a2c3fe1c..05ebf707 100644
--- a/completions/just.elvish
+++ b/completions/just.elvish
@@ -47,6 +47,7 @@ edit:completion:arg-completer[just] = [@words]{
             cand -e 'Edit justfile with editor given by $VISUAL or $EDITOR, falling back to `vim`'
             cand --edit 'Edit justfile with editor given by $VISUAL or $EDITOR, falling back to `vim`'
             cand --evaluate 'Evaluate and print all variables. If a variable name is given as an argument, only print that variable''s value.'
+            cand --fmt 'Format and overwrite justfile'
             cand --init 'Initialize new justfile in project root'
             cand -l 'List available recipes and their arguments'
             cand --list 'List available recipes and their arguments'
diff --git a/completions/just.fish b/completions/just.fish
index 83f2267e..887910cb 100644
--- a/completions/just.fish
+++ b/completions/just.fish
@@ -34,6 +34,7 @@ complete -c just -n "__fish_use_subcommand" -l choose -d 'Select one or more rec
 complete -c just -n "__fish_use_subcommand" -l dump -d 'Print entire justfile'
 complete -c just -n "__fish_use_subcommand" -s e -l edit -d 'Edit justfile with editor given by $VISUAL or $EDITOR, falling back to `vim`'
 complete -c just -n "__fish_use_subcommand" -l evaluate -d 'Evaluate and print all variables. If a variable name is given as an argument, only print that variable\'s value.'
+complete -c just -n "__fish_use_subcommand" -l fmt -d 'Format and overwrite justfile'
 complete -c just -n "__fish_use_subcommand" -l init -d 'Initialize new justfile in project root'
 complete -c just -n "__fish_use_subcommand" -s l -l list -d 'List available recipes and their arguments'
 complete -c just -n "__fish_use_subcommand" -l summary -d 'List names of available recipes'
diff --git a/completions/just.powershell b/completions/just.powershell
index 64d8990c..ed917621 100644
--- a/completions/just.powershell
+++ b/completions/just.powershell
@@ -52,6 +52,7 @@ Register-ArgumentCompleter -Native -CommandName 'just' -ScriptBlock {
             [CompletionResult]::new('-e', 'e', [CompletionResultType]::ParameterName, 'Edit justfile with editor given by $VISUAL or $EDITOR, falling back to `vim`')
             [CompletionResult]::new('--edit', 'edit', [CompletionResultType]::ParameterName, 'Edit justfile with editor given by $VISUAL or $EDITOR, falling back to `vim`')
             [CompletionResult]::new('--evaluate', 'evaluate', [CompletionResultType]::ParameterName, 'Evaluate and print all variables. If a variable name is given as an argument, only print that variable''s value.')
+            [CompletionResult]::new('--fmt', 'fmt', [CompletionResultType]::ParameterName, 'Format and overwrite justfile')
             [CompletionResult]::new('--init', 'init', [CompletionResultType]::ParameterName, 'Initialize new justfile in project root')
             [CompletionResult]::new('-l', 'l', [CompletionResultType]::ParameterName, 'List available recipes and their arguments')
             [CompletionResult]::new('--list', 'list', [CompletionResultType]::ParameterName, 'List available recipes and their arguments')
diff --git a/completions/just.zsh b/completions/just.zsh
index d31d1a5e..5d832461 100644
--- a/completions/just.zsh
+++ b/completions/just.zsh
@@ -48,6 +48,7 @@ _just() {
 '-e[Edit justfile with editor given by $VISUAL or $EDITOR, falling back to `vim`]' \
 '--edit[Edit justfile with editor given by $VISUAL or $EDITOR, falling back to `vim`]' \
 '--evaluate[Evaluate and print all variables. If a variable name is given as an argument, only print that variable'\''s value.]' \
+'--fmt[Format and overwrite justfile]' \
 '--init[Initialize new justfile in project root]' \
 '-l[List available recipes and their arguments]' \
 '--list[List available recipes and their arguments]' \
diff --git a/src/alias.rs b/src/alias.rs
index 37eef0cf..5dafe125 100644
--- a/src/alias.rs
+++ b/src/alias.rs
@@ -1,7 +1,7 @@
 use crate::common::*;
 
 /// An alias, e.g. `name := target`
-#[derive(Debug, PartialEq)]
+#[derive(Debug, PartialEq, Clone)]
 pub(crate) struct Alias<'src, T = Rc<Recipe<'src>>> {
   pub(crate) name:   Name<'src>,
   pub(crate) target: T,
diff --git a/src/analyzer.rs b/src/analyzer.rs
index 55cf6487..fbf32d96 100644
--- a/src/analyzer.rs
+++ b/src/analyzer.rs
@@ -29,6 +29,7 @@ impl<'src> Analyzer<'src> {
           self.analyze_assignment(&assignment)?;
           self.assignments.insert(assignment);
         },
+        Item::Comment(_) => (),
         Item::Recipe(recipe) => {
           self.analyze_recipe(&recipe)?;
           self.recipes.insert(recipe);
diff --git a/src/assignment.rs b/src/assignment.rs
index 52c2e9cd..5ea7bc18 100644
--- a/src/assignment.rs
+++ b/src/assignment.rs
@@ -2,3 +2,12 @@ use crate::common::*;
 
 /// An assignment, e.g `foo := bar`
 pub(crate) type Assignment<'src> = Binding<'src, Expression<'src>>;
+
+impl<'src> Display for Assignment<'src> {
+  fn fmt(&self, f: &mut Formatter) -> Result<(), fmt::Error> {
+    if self.export {
+      write!(f, "export ")?;
+    }
+    write!(f, "{} := {}", self.name, self.value)
+  }
+}
diff --git a/src/binding.rs b/src/binding.rs
index 85dbd4ad..185c4ad6 100644
--- a/src/binding.rs
+++ b/src/binding.rs
@@ -1,7 +1,7 @@
 use crate::common::*;
 
 /// A binding of `name` to `value`
-#[derive(Debug, PartialEq)]
+#[derive(Debug, Clone, PartialEq)]
 pub(crate) struct Binding<'src, V = String> {
   /// Export binding as an environment variable to child processes
   pub(crate) export: bool,
diff --git a/src/common.rs b/src/common.rs
index d7d0db19..95c13c7f 100644
--- a/src/common.rs
+++ b/src/common.rs
@@ -5,9 +5,10 @@ pub(crate) use std::{
   env,
   ffi::{OsStr, OsString},
   fmt::{self, Debug, Display, Formatter},
-  fs,
+  fs::{self, File},
   io::{self, Cursor, Write},
   iter::{self, FromIterator},
+  mem,
   ops::{Index, Range, RangeInclusive},
   path::{Path, PathBuf},
   process::{self, Command, Stdio},
@@ -45,21 +46,21 @@ pub(crate) use crate::{
   alias::Alias, analyzer::Analyzer, assignment::Assignment,
   assignment_resolver::AssignmentResolver, binding::Binding, color::Color,
   compilation_error::CompilationError, compilation_error_kind::CompilationErrorKind,
-  compiler::Compiler, config::Config, config_error::ConfigError, count::Count,
-  delimiter::Delimiter, dependency::Dependency, enclosure::Enclosure, evaluator::Evaluator,
-  expression::Expression, fragment::Fragment, function::Function,
-  function_context::FunctionContext, interrupt_guard::InterruptGuard,
-  interrupt_handler::InterruptHandler, item::Item, justfile::Justfile, keyword::Keyword,
-  lexer::Lexer, line::Line, list::List, load_error::LoadError, module::Module, name::Name,
-  output_error::OutputError, parameter::Parameter, parameter_kind::ParameterKind, parser::Parser,
-  platform::Platform, position::Position, positional::Positional, recipe::Recipe,
-  recipe_context::RecipeContext, recipe_resolver::RecipeResolver, runtime_error::RuntimeError,
-  scope::Scope, search::Search, search_config::SearchConfig, search_error::SearchError, set::Set,
-  setting::Setting, settings::Settings, shebang::Shebang, show_whitespace::ShowWhitespace,
-  string_kind::StringKind, string_literal::StringLiteral, subcommand::Subcommand,
-  suggestion::Suggestion, table::Table, thunk::Thunk, token::Token, token_kind::TokenKind,
-  unresolved_dependency::UnresolvedDependency, unresolved_recipe::UnresolvedRecipe,
-  use_color::UseColor, variables::Variables, verbosity::Verbosity, warning::Warning,
+  config::Config, config_error::ConfigError, count::Count, delimiter::Delimiter,
+  dependency::Dependency, enclosure::Enclosure, evaluator::Evaluator, expression::Expression,
+  fragment::Fragment, function::Function, function_context::FunctionContext,
+  interrupt_guard::InterruptGuard, interrupt_handler::InterruptHandler, item::Item,
+  justfile::Justfile, keyword::Keyword, lexer::Lexer, line::Line, list::List,
+  load_error::LoadError, module::Module, name::Name, output_error::OutputError,
+  parameter::Parameter, parameter_kind::ParameterKind, parser::Parser, platform::Platform,
+  position::Position, positional::Positional, recipe::Recipe, recipe_context::RecipeContext,
+  recipe_resolver::RecipeResolver, runtime_error::RuntimeError, scope::Scope, search::Search,
+  search_config::SearchConfig, search_error::SearchError, set::Set, setting::Setting,
+  settings::Settings, shebang::Shebang, show_whitespace::ShowWhitespace, string_kind::StringKind,
+  string_literal::StringLiteral, subcommand::Subcommand, suggestion::Suggestion, table::Table,
+  thunk::Thunk, token::Token, token_kind::TokenKind, unresolved_dependency::UnresolvedDependency,
+  unresolved_recipe::UnresolvedRecipe, use_color::UseColor, variables::Variables,
+  verbosity::Verbosity, warning::Warning,
 };
 
 // type aliases
diff --git a/src/config.rs b/src/config.rs
index 883350f4..79533b59 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -38,6 +38,7 @@ mod cmd {
   pub(crate) const DUMP: &str = "DUMP";
   pub(crate) const EDIT: &str = "EDIT";
   pub(crate) const EVALUATE: &str = "EVALUATE";
+  pub(crate) const FORMAT: &str = "FORMAT";
   pub(crate) const INIT: &str = "INIT";
   pub(crate) const LIST: &str = "LIST";
   pub(crate) const SHOW: &str = "SHOW";
@@ -52,6 +53,7 @@ mod cmd {
     DUMP,
     EDIT,
     EVALUATE,
+    FORMAT,
     INIT,
     LIST,
     SHOW,
@@ -63,6 +65,7 @@ mod cmd {
     COMPLETIONS,
     DUMP,
     EDIT,
+    FORMAT,
     INIT,
     LIST,
     SHOW,
@@ -266,6 +269,11 @@ impl Config {
         "Evaluate and print all variables. If a variable name is given as an argument, only print \
          that variable's value.",
       ))
+      .arg(
+        Arg::with_name(cmd::FORMAT)
+          .long("fmt")
+          .help("Format and overwrite justfile"),
+      )
       .arg(
         Arg::with_name(cmd::INIT)
           .long("init")
@@ -442,6 +450,8 @@ impl Config {
       Subcommand::Summary
     } else if matches.is_present(cmd::DUMP) {
       Subcommand::Dump
+    } else if matches.is_present(cmd::FORMAT) {
+      Subcommand::Format
     } else if matches.is_present(cmd::INIT) {
       Subcommand::Init
     } else if matches.is_present(cmd::LIST) {
@@ -539,7 +549,9 @@ impl Config {
       })
       .eprint(self.color)?;
 
-    let justfile = Compiler::compile(&src).eprint(self.color)?;
+    let tokens = Lexer::lex(&src).eprint(self.color)?;
+    let ast = Parser::parse(&tokens).eprint(self.color)?;
+    let justfile = Analyzer::analyze(ast.clone()).eprint(self.color)?;
 
     if self.verbosity.loud() {
       for warning in &justfile.warnings {
@@ -555,8 +567,9 @@ impl Config {
       Choose { overrides, chooser } =>
         self.choose(justfile, &search, overrides, chooser.as_deref())?,
       Command { overrides, .. } => self.run(justfile, &search, overrides, &[])?,
-      Dump => Self::dump(justfile),
+      Dump => Self::dump(ast)?,
       Evaluate { overrides, .. } => self.run(justfile, &search, overrides, &[])?,
+      Format => self.format(ast, &search)?,
       List => self.list(justfile),
       Run {
         arguments,
@@ -676,8 +689,9 @@ impl Config {
     self.run(justfile, search, overrides, &recipes)
   }
 
-  fn dump(justfile: Justfile) {
-    println!("{}", justfile);
+  fn dump(ast: Module) -> Result<(), i32> {
+    print!("{}", ast);
+    Ok(())
   }
 
   pub(crate) fn edit(&self, search: &Search) -> Result<(), i32> {
@@ -713,6 +727,24 @@ impl Config {
     }
   }
 
+  fn format(&self, ast: Module, search: &Search) -> Result<(), i32> {
+    if let Err(error) = File::open(&search.justfile).and_then(|mut file| write!(file, "{}", ast)) {
+      if self.verbosity.loud() {
+        eprintln!(
+          "Failed to write justfile to `{}`: {}",
+          search.justfile.display(),
+          error
+        );
+      }
+      Err(EXIT_FAILURE)
+    } else {
+      if self.verbosity.loud() {
+        eprintln!("Wrote justfile to `{}`", search.justfile.display());
+      }
+      Ok(())
+    }
+  }
+
   pub(crate) fn init(&self) -> Result<(), i32> {
     let search =
       Search::init(&self.search_config, &self.invocation_directory).eprint(self.color)?;
@@ -920,6 +952,7 @@ FLAGS:
         --evaluate            Evaluate and print all variables. If a variable name is given as an \
                                  argument, only print
                               that variable's value.
+        --fmt                 Format and overwrite justfile
         --highlight           Highlight echoed recipe lines in bold
         --init                Initialize new justfile in project root
     -l, --list                List available recipes and their arguments
@@ -1315,6 +1348,11 @@ ARGS:
     args: ["--list", "--dump"],
   }
 
+  error! {
+    name: subcommand_conflict_fmt,
+    args: ["--list", "--fmt"],
+  }
+
   error! {
     name: subcommand_conflict_init,
     args: ["--list", "--init"],
@@ -1661,6 +1699,16 @@ ARGS:
     },
   }
 
+  error! {
+    name: fmt_arguments,
+    args: ["--fmt", "bar"],
+    error: ConfigError::SubcommandArguments { subcommand, arguments },
+    check: {
+      assert_eq!(subcommand, cmd::FORMAT);
+      assert_eq!(arguments, &["bar"]);
+    },
+  }
+
   error! {
     name: init_arguments,
     args: ["--init", "bar"],
diff --git a/src/expression.rs b/src/expression.rs
index fe8ff00f..67a54e61 100644
--- a/src/expression.rs
+++ b/src/expression.rs
@@ -6,7 +6,7 @@ use crate::common::*;
 /// parenthetical groups).
 ///
 /// The parser parses both values and expressions into `Expression`s.
-#[derive(PartialEq, Debug)]
+#[derive(PartialEq, Debug, Clone)]
 pub(crate) enum Expression<'src> {
   /// `contents`
   Backtick {
@@ -45,7 +45,7 @@ impl<'src> Expression<'src> {
 impl<'src> Display for Expression<'src> {
   fn fmt(&self, f: &mut Formatter) -> Result<(), fmt::Error> {
     match self {
-      Expression::Backtick { contents, .. } => write!(f, "`{}`", contents),
+      Expression::Backtick { token, .. } => write!(f, "{}", token.lexeme()),
       Expression::Concatination { lhs, rhs } => write!(f, "{} + {}", lhs, rhs),
       Expression::Conditional {
         lhs,
@@ -55,7 +55,7 @@ impl<'src> Display for Expression<'src> {
         inverted,
       } => write!(
         f,
-        "if {} {} {} {{ {} }} else {{ {} }} ",
+        "if {} {} {} {{ {} }} else {{ {} }}",
         lhs,
         if *inverted { "!=" } else { "==" },
         rhs,
diff --git a/src/fragment.rs b/src/fragment.rs
index 48aec9f1..e43dfa2d 100644
--- a/src/fragment.rs
+++ b/src/fragment.rs
@@ -1,7 +1,7 @@
 use crate::common::*;
 
 /// A line fragment consisting either of…
-#[derive(PartialEq, Debug)]
+#[derive(PartialEq, Debug, Clone)]
 pub(crate) enum Fragment<'src> {
   /// …raw text…
   Text { token: Token<'src> },
diff --git a/src/item.rs b/src/item.rs
index 0af1d0bf..21fa54ac 100644
--- a/src/item.rs
+++ b/src/item.rs
@@ -1,10 +1,23 @@
 use crate::common::*;
 
 /// A single top-level item
-#[derive(Debug)]
+#[derive(Debug, Clone)]
 pub(crate) enum Item<'src> {
   Alias(Alias<'src, Name<'src>>),
   Assignment(Assignment<'src>),
+  Comment(&'src str),
   Recipe(UnresolvedRecipe<'src>),
   Set(Set<'src>),
 }
+
+impl<'src> Display for Item<'src> {
+  fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+    match self {
+      Item::Alias(alias) => write!(f, "{}", alias),
+      Item::Assignment(assignment) => write!(f, "{}", assignment),
+      Item::Comment(comment) => write!(f, "{}", comment),
+      Item::Recipe(recipe) => write!(f, "{}", recipe),
+      Item::Set(set) => write!(f, "{}", set),
+    }
+  }
+}
diff --git a/src/justfile.rs b/src/justfile.rs
index cfd3cee5..08a8bd2f 100644
--- a/src/justfile.rs
+++ b/src/justfile.rs
@@ -802,7 +802,7 @@ goodbye := \"y\"
 hello a b c: x y z
     #! blah
     #blarg
-    {{foo + bar}}abc{{goodbye + \"x\"}}xyz
+    {{ foo + bar }}abc{{ goodbye + \"x\" }}xyz
     1
     2
     3
@@ -828,7 +828,7 @@ install:
 
 install:
     #!/bin/sh
-    if [[ -f {{practicum}} ]]; then
+    if [[ -f {{ practicum }} ]]; then
     \treturn
     fi",
   }
@@ -869,7 +869,7 @@ c := a + b + a + b",
     r#"a:
   echo {{  `echo hello` + "blarg"   }} {{   `echo bob`   }}"#,
     r#"a:
-    echo {{`echo hello` + "blarg"}} {{`echo bob`}}"#,
+    echo {{ `echo hello` + "blarg" }} {{ `echo bob` }}"#,
   }
 
   test! {
@@ -895,7 +895,7 @@ c := a + b + a + b",
     "a b c:
   {{b}} {{c}}",
     "a b c:
-    {{b}} {{c}}",
+    {{ b }} {{ c }}",
   }
 
   test! {
@@ -908,7 +908,7 @@ a:
     "x := arch()
 
 a:
-    {{os()}} {{os_family()}}",
+    {{ os() }} {{ os_family() }}",
   }
 
   test! {
@@ -921,7 +921,7 @@ a:
     r#"x := env_var('foo')
 
 a:
-    {{env_var_or_default('foo' + 'bar', 'baz')}} {{env_var(env_var("baz"))}}"#,
+    {{ env_var_or_default('foo' + 'bar', 'baz') }} {{ env_var(env_var("baz")) }}"#,
   }
 
   test! {
diff --git a/src/line.rs b/src/line.rs
index f296627d..7b4b4c8f 100644
--- a/src/line.rs
+++ b/src/line.rs
@@ -1,7 +1,7 @@
 use crate::common::*;
 
 /// A single line in a recipe body, consisting of any number of `Fragment`s.
-#[derive(Debug, PartialEq)]
+#[derive(Debug, Clone, PartialEq)]
 pub(crate) struct Line<'src> {
   pub(crate) fragments: Vec<Fragment<'src>>,
 }
diff --git a/src/module.rs b/src/module.rs
index a2f06402..d83a2b5b 100644
--- a/src/module.rs
+++ b/src/module.rs
@@ -7,10 +7,30 @@ use crate::common::*;
 /// Not all successful parses result in valid justfiles, so additional
 /// consistency checks and name resolution are performed by the `Analyzer`,
 /// which produces a `Justfile` from a `Module`.
-#[derive(Debug)]
+#[derive(Debug, Clone)]
 pub(crate) struct Module<'src> {
   /// Items in the justfile
   pub(crate) items:    Vec<Item<'src>>,
   /// Non-fatal warnings encountered during parsing
   pub(crate) warnings: Vec<Warning>,
 }
+
+impl<'src> Display for Module<'src> {
+  fn fmt(&self, f: &mut Formatter) -> Result<(), fmt::Error> {
+    let mut iter = self.items.iter().peekable();
+
+    while let Some(item) = iter.next() {
+      writeln!(f, "{}", item)?;
+
+      if let Some(next_item) = iter.peek() {
+        if matches!(item, Item::Recipe(_))
+          || mem::discriminant(item) != mem::discriminant(next_item)
+        {
+          writeln!(f)?;
+        }
+      }
+    }
+
+    Ok(())
+  }
+}
diff --git a/src/node.rs b/src/node.rs
index 19be563f..aebfbbf4 100644
--- a/src/node.rs
+++ b/src/node.rs
@@ -20,6 +20,7 @@ impl<'src> Node<'src> for Item<'src> {
     match self {
       Item::Alias(alias) => alias.tree(),
       Item::Assignment(assignment) => assignment.tree(),
+      Item::Comment(comment) => comment.tree(),
       Item::Recipe(recipe) => recipe.tree(),
       Item::Set(set) => set.tree(),
     }
@@ -210,3 +211,9 @@ impl<'src> Node<'src> for Warning {
     unreachable!()
   }
 }
+
+impl<'src> Node<'src> for str {
+  fn tree(&self) -> Tree<'src> {
+    Tree::atom("comment").push(["\"", self, "\""].concat())
+  }
+}
diff --git a/src/parameter.rs b/src/parameter.rs
index 1806eed4..ee0d93b2 100644
--- a/src/parameter.rs
+++ b/src/parameter.rs
@@ -1,7 +1,7 @@
 use crate::common::*;
 
 /// A single function parameter
-#[derive(PartialEq, Debug)]
+#[derive(PartialEq, Debug, Clone)]
 pub(crate) struct Parameter<'src> {
   /// The parameter name
   pub(crate) name:    Name<'src>,
@@ -16,6 +16,9 @@ pub(crate) struct Parameter<'src> {
 impl<'src> Display for Parameter<'src> {
   fn fmt(&self, f: &mut Formatter) -> Result<(), fmt::Error> {
     let color = Color::fmt(f);
+    if self.export {
+      write!(f, "$")?;
+    }
     if let Some(prefix) = self.kind.prefix() {
       write!(f, "{}", color.annotation().paint(prefix))?;
     }
diff --git a/src/parser.rs b/src/parser.rs
index 6f1286be..45c4c809 100644
--- a/src/parser.rs
+++ b/src/parser.rs
@@ -296,17 +296,34 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
 
   /// Parse a justfile, consumes self
   fn parse_justfile(mut self) -> CompilationResult<'src, Module<'src>> {
+    fn pop_doc_comment<'src>(
+      items: &mut Vec<Item<'src>>,
+      eol_since_last_comment: bool,
+    ) -> Option<&'src str> {
+      if !eol_since_last_comment {
+        if let Some(Item::Comment(contents)) = items.last() {
+          let doc = Some(contents[1..].trim_start());
+          items.pop();
+          return doc;
+        }
+      }
+
+      None
+    }
+
     let mut items = Vec::new();
 
-    let mut doc = None;
+    let mut eol_since_last_comment = false;
 
     loop {
       let next = self.next()?;
 
       if let Some(comment) = self.accept(Comment)? {
-        doc = Some(comment.lexeme()[1..].trim());
+        items.push(Item::Comment(comment.lexeme().trim_end()));
         self.expect_eol()?;
+        eol_since_last_comment = false;
       } else if self.accepted(Eol)? {
+        eol_since_last_comment = true;
       } else if self.accepted(Eof)? {
         break;
       } else if self.next_is(Identifier) {
@@ -317,6 +334,7 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
             } else if self.next_are(&[Identifier, Identifier, ColonEquals]) {
               items.push(Item::Alias(self.parse_alias()?));
             } else {
+              let doc = pop_doc_comment(&mut items, eol_since_last_comment);
               items.push(Item::Recipe(self.parse_recipe(doc, false)?));
             },
           Some(Keyword::Export) =>
@@ -326,6 +344,7 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
               self.presume_keyword(Keyword::Export)?;
               items.push(Item::Assignment(self.parse_assignment(true)?));
             } else {
+              let doc = pop_doc_comment(&mut items, eol_since_last_comment);
               items.push(Item::Recipe(self.parse_recipe(doc, false)?));
             },
           Some(Keyword::Set) =>
@@ -335,6 +354,7 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
             {
               items.push(Item::Set(self.parse_set()?));
             } else {
+              let doc = pop_doc_comment(&mut items, eol_since_last_comment);
               items.push(Item::Recipe(self.parse_recipe(doc, false)?));
             },
           _ =>
@@ -343,18 +363,16 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
             } else if self.next_are(&[Identifier, ColonEquals]) {
               items.push(Item::Assignment(self.parse_assignment(false)?));
             } else {
+              let doc = pop_doc_comment(&mut items, eol_since_last_comment);
               items.push(Item::Recipe(self.parse_recipe(doc, false)?));
             },
         }
       } else if self.accepted(At)? {
+        let doc = pop_doc_comment(&mut items, eol_since_last_comment);
         items.push(Item::Recipe(self.parse_recipe(doc, true)?));
       } else {
         return Err(self.unexpected_token()?);
       }
-
-      if next.kind != Comment {
-        doc = None;
-      }
     }
 
     if self.next != self.tokens.len() {
@@ -1097,11 +1115,17 @@ mod tests {
   test! {
     name: comment,
     text: "# foo",
-    tree: (justfile),
+    tree: (justfile (comment "# foo")),
+  }
+
+  test! {
+    name: comment_before_alias,
+    text: "# foo\nalias x := y",
+    tree: (justfile (comment "# foo") (alias x y)),
   }
 
   test! {
-    name: comment_alias,
+    name: comment_after_alias,
     text: "alias x := y # foo",
     tree: (justfile (alias x y)),
   }
@@ -1166,7 +1190,7 @@ mod tests {
       x := y
       bar:
     ",
-    tree: (justfile (assignment x y) (recipe bar)),
+    tree: (justfile (comment "# foo") (assignment x y) (recipe bar)),
   }
 
   test! {
@@ -1176,7 +1200,7 @@ mod tests {
 
       bar:
     ",
-    tree: (justfile (recipe bar)),
+    tree: (justfile (comment "# foo") (recipe bar)),
   }
 
   test! {
diff --git a/src/recipe.rs b/src/recipe.rs
index 993d2f8f..c7b2d71b 100644
--- a/src/recipe.rs
+++ b/src/recipe.rs
@@ -23,7 +23,7 @@ fn error_from_signal(
 }
 
 /// A recipe, e.g. `foo: bar baz`
-#[derive(PartialEq, Debug)]
+#[derive(PartialEq, Debug, Clone)]
 pub(crate) struct Recipe<'src, D = Dependency<'src>> {
   pub(crate) dependencies: Vec<D>,
   pub(crate) doc:          Option<&'src str>,
@@ -314,7 +314,7 @@ impl<'src, D> Keyed<'src> for Recipe<'src, D> {
   }
 }
 
-impl<'src> Display for Recipe<'src> {
+impl<'src, D: Display> Display for Recipe<'src, D> {
   fn fmt(&self, f: &mut Formatter) -> Result<(), fmt::Error> {
     if let Some(doc) = self.doc {
       writeln!(f, "# {}", doc)?;
@@ -344,7 +344,7 @@ impl<'src> Display for Recipe<'src> {
         }
         match fragment {
           Fragment::Text { token } => write!(f, "{}", token.lexeme())?,
-          Fragment::Interpolation { expression, .. } => write!(f, "{{{{{}}}}}", expression)?,
+          Fragment::Interpolation { expression, .. } => write!(f, "{{{{ {} }}}}", expression)?,
         }
       }
       if i + 1 < self.body.len() {
diff --git a/src/set.rs b/src/set.rs
index ade63fe9..b72887af 100644
--- a/src/set.rs
+++ b/src/set.rs
@@ -1,6 +1,6 @@
 use crate::common::*;
 
-#[derive(Debug)]
+#[derive(Debug, Clone)]
 pub(crate) struct Set<'src> {
   pub(crate) name:  Name<'src>,
   pub(crate) value: Setting<'src>,
@@ -11,3 +11,9 @@ impl<'src> Keyed<'src> for Set<'src> {
     self.name.lexeme()
   }
 }
+
+impl<'src> Display for Set<'src> {
+  fn fmt(&self, f: &mut Formatter) -> Result<(), fmt::Error> {
+    write!(f, "set {} := {}", self.name, self.value)
+  }
+}
diff --git a/src/setting.rs b/src/setting.rs
index 011e3c02..8f6b5b0b 100644
--- a/src/setting.rs
+++ b/src/setting.rs
@@ -1,6 +1,6 @@
 use crate::common::*;
 
-#[derive(Debug)]
+#[derive(Debug, Clone)]
 pub(crate) enum Setting<'src> {
   DotenvLoad(bool),
   Export(bool),
@@ -8,8 +8,31 @@ pub(crate) enum Setting<'src> {
   Shell(Shell<'src>),
 }
 
-#[derive(Debug, PartialEq)]
+#[derive(Debug, Clone, PartialEq)]
 pub(crate) struct Shell<'src> {
   pub(crate) command:   StringLiteral<'src>,
   pub(crate) arguments: Vec<StringLiteral<'src>>,
 }
+
+impl<'src> Display for Setting<'src> {
+  fn fmt(&self, f: &mut Formatter) -> Result<(), fmt::Error> {
+    match self {
+      Setting::DotenvLoad(value) => write!(f, "{}", value),
+      Setting::Export(value) => write!(f, "{}", value),
+      Setting::PositionalArguments(value) => write!(f, "{}", value),
+      Setting::Shell(shell) => write!(f, "{}", shell),
+    }
+  }
+}
+
+impl<'src> Display for Shell<'src> {
+  fn fmt(&self, f: &mut Formatter) -> Result<(), fmt::Error> {
+    write!(f, "[{}", self.command)?;
+
+    for argument in &self.arguments {
+      write!(f, ", {}", argument)?;
+    }
+
+    write!(f, "]")
+  }
+}
diff --git a/src/string_literal.rs b/src/string_literal.rs
index 7034366d..115d3e2c 100644
--- a/src/string_literal.rs
+++ b/src/string_literal.rs
@@ -1,6 +1,6 @@
 use crate::common::*;
 
-#[derive(PartialEq, Debug)]
+#[derive(PartialEq, Debug, Clone)]
 pub(crate) struct StringLiteral<'src> {
   pub(crate) kind:   StringKind,
   pub(crate) raw:    &'src str,
diff --git a/src/subcommand.rs b/src/subcommand.rs
index a3084c63..ee5bd1c1 100644
--- a/src/subcommand.rs
+++ b/src/subcommand.rs
@@ -20,6 +20,7 @@ pub(crate) enum Subcommand {
     overrides: BTreeMap<String, String>,
     variable:  Option<String>,
   },
+  Format,
   Init,
   List,
   Run {
diff --git a/src/thunk.rs b/src/thunk.rs
index 991b62f5..bbc45c7f 100644
--- a/src/thunk.rs
+++ b/src/thunk.rs
@@ -1,7 +1,7 @@
 use crate::common::*;
 
 #[derive(Derivative)]
-#[derivative(Debug, PartialEq = "feature_allow_slow_enum")]
+#[derivative(Debug, Clone, PartialEq = "feature_allow_slow_enum")]
 pub(crate) enum Thunk<'src> {
   Nullary {
     name:     Name<'src>,
diff --git a/src/unresolved_dependency.rs b/src/unresolved_dependency.rs
index de35f12e..47f0dea9 100644
--- a/src/unresolved_dependency.rs
+++ b/src/unresolved_dependency.rs
@@ -1,7 +1,23 @@
 use crate::common::*;
 
-#[derive(PartialEq, Debug)]
+#[derive(PartialEq, Debug, Clone)]
 pub(crate) struct UnresolvedDependency<'src> {
   pub(crate) recipe:    Name<'src>,
   pub(crate) arguments: Vec<Expression<'src>>,
 }
+
+impl<'src> Display for UnresolvedDependency<'src> {
+  fn fmt(&self, f: &mut Formatter) -> Result<(), fmt::Error> {
+    if self.arguments.is_empty() {
+      write!(f, "{}", self.recipe)
+    } else {
+      write!(f, "({}", self.recipe)?;
+
+      for argument in &self.arguments {
+        write!(f, " {}", argument)?;
+      }
+
+      write!(f, ")")
+    }
+  }
+}
diff --git a/src/warning.rs b/src/warning.rs
index 455bc98f..da3c8b13 100644
--- a/src/warning.rs
+++ b/src/warning.rs
@@ -1,6 +1,6 @@
 use crate::common::*;
 
-#[derive(Debug, PartialEq)]
+#[derive(Clone, Debug, PartialEq)]
 pub(crate) enum Warning {
   // Remove this on 2021-07-01.
   #[allow(dead_code)]


Test patch:
diff --git a/src/testing.rs b/src/testing.rs
index 1ac4c45a..bbf7d298 100644
--- a/src/testing.rs
+++ b/src/testing.rs
@@ -1,5 +1,6 @@
 use crate::common::*;
 
+use crate::compiler::Compiler;
 use pretty_assertions::assert_eq;
 
 pub(crate) fn compile(text: &str) -> Justfile {
diff --git a/tests/command.rs b/tests/command.rs
index f740135d..f3caca95 100644
--- a/tests/command.rs
+++ b/tests/command.rs
@@ -33,7 +33,7 @@ test! {
     USAGE:
         just{} --color <COLOR> --shell <SHELL> --shell-arg <SHELL-ARG>... \
         <--choose|--command <COMMAND>|--completions <SHELL>|--dump|--edit|\
-        --evaluate|--init|--list|--show <RECIPE>|--summary|--variables>
+        --evaluate|--fmt|--init|--list|--show <RECIPE>|--summary|--variables>
 
     For more information try --help
   ", EXE_SUFFIX),
diff --git a/tests/conditional.rs b/tests/conditional.rs
index 7e02c632..f1b2e221 100644
--- a/tests/conditional.rs
+++ b/tests/conditional.rs
@@ -149,10 +149,10 @@ test! {
       echo {{ a }}
   ",
   args: ("--dump"),
-  stdout: format!("
-    a := if '' == '' {{ '' }} else {{ '' }}{}
+  stdout: "
+    a := if '' == '' { '' } else { '' }
 
     foo:
-        echo {{{{a}}}}
-  ", " ").as_str(),
+        echo {{ a }}
+  ",
 }
diff --git a/tests/fmt.rs b/tests/fmt.rs
new file mode 100644
index 00000000..056f285e
--- /dev/null
+++ b/tests/fmt.rs
@@ -0,0 +1,891 @@
+test! {
+  name: alias_good,
+  justfile: "
+    alias f := foo
+
+    foo:
+        echo foo
+  ",
+  args: ("--dump"),
+  stdout: "
+    alias f := foo
+
+    foo:
+        echo foo
+  ",
+}
+
+test! {
+  name: alias_fix_indent,
+  justfile: "
+    alias f:=    foo
+
+    foo:
+        echo foo
+  ",
+  args: ("--dump"),
+  stdout: "
+    alias f := foo
+
+    foo:
+        echo foo
+  ",
+}
+
+test! {
+  name: assignment_singlequote,
+  justfile: "
+    foo := 'foo'
+  ",
+  args: ("--dump"),
+  stdout: "
+    foo := 'foo'
+  ",
+}
+
+test! {
+  name: assignment_doublequote,
+  justfile: r#"
+    foo := "foo"
+  "#,
+  args: ("--dump"),
+  stdout: r#"
+    foo := "foo"
+  "#,
+}
+
+test! {
+  name: assignment_indented_singlequote,
+  justfile: "
+    foo := '''
+      foo
+    '''
+  ",
+  args: ("--dump"),
+  stdout: r"
+    foo := '''
+      foo
+    '''
+  ",
+}
+
+test! {
+  name: assignment_indented_doublequote,
+  justfile: r#"
+    foo := """
+      foo
+    """
+  "#,
+  args: ("--dump"),
+  stdout: r#"
+    foo := """
+      foo
+    """
+  "#,
+}
+
+test! {
+  name: assignment_backtick,
+  justfile: "
+    foo := `foo`
+  ",
+  args: ("--dump"),
+  stdout: "
+    foo := `foo`
+  ",
+}
+
+test! {
+  name: assignment_indented_backtick,
+  justfile: "
+    foo := ```
+      foo
+    ```
+  ",
+  args: ("--dump"),
+  stdout: "
+    foo := ```
+      foo
+    ```
+  ",
+}
+
+test! {
+  name: assignment_name,
+  justfile: "
+    bar := 'bar'
+    foo := bar
+  ",
+  args: ("--dump"),
+  stdout: "
+    bar := 'bar'
+    foo := bar
+  ",
+}
+
+test! {
+  name: assignment_parenthized_expression,
+  justfile: "
+    foo := ('foo')
+  ",
+  args: ("--dump"),
+  stdout: "
+    foo := ('foo')
+  ",
+}
+
+test! {
+  name: assignment_export,
+  justfile: "
+    export foo := 'foo'
+  ",
+  args: ("--dump"),
+  stdout: "
+    export foo := 'foo'
+  ",
+}
+
+test! {
+  name: assignment_concat_values,
+  justfile: "
+    foo := 'foo' + 'bar'
+  ",
+  args: ("--dump"),
+  stdout: "
+    foo := 'foo' + 'bar'
+  ",
+}
+
+test! {
+  name: assignment_if_oneline,
+  justfile: "
+    foo := if 'foo' == 'foo' { 'foo' } else { 'bar' }
+  ",
+  args: ("--dump"),
+  stdout: "
+    foo := if 'foo' == 'foo' { 'foo' } else { 'bar' }
+  ",
+}
+
+test! {
+  name: assignment_if_multiline,
+  justfile: "
+    foo := if 'foo' != 'foo' {
+      'foo'
+    } else {
+      'bar'
+    }
+  ",
+  args: ("--dump"),
+  stdout: "
+    foo := if 'foo' != 'foo' { 'foo' } else { 'bar' }
+  ",
+}
+
+test! {
+  name: assignment_nullary_function,
+  justfile: "
+    foo := arch()
+  ",
+  args: ("--dump"),
+  stdout: "
+    foo := arch()
+  ",
+}
+
+test! {
+  name: assignment_unary_function,
+  justfile: "
+    foo := env_var('foo')
+  ",
+  args: ("--dump"),
+  stdout: "
+    foo := env_var('foo')
+  ",
+}
+
+test! {
+  name: assignment_binary_function,
+  justfile: "
+    foo := env_var_or_default('foo', 'bar')
+  ",
+  args: ("--dump"),
+  stdout: "
+    foo := env_var_or_default('foo', 'bar')
+  ",
+}
+
+test! {
+  name: recipe_ordinary,
+  justfile: "
+    foo:
+        echo bar
+  ",
+  args: ("--dump"),
+  stdout: "
+    foo:
+        echo bar
+  ",
+}
+
+test! {
+  name: recipe_with_docstring,
+  justfile: "
+    # bar
+    foo:
+        echo bar
+  ",
+  args: ("--dump"),
+  stdout: "
+    # bar
+    foo:
+        echo bar
+  ",
+}
+
+test! {
+  name: recipe_with_comments_in_body,
+  justfile: "
+    foo:
+        # bar
+        echo bar
+  ",
+  args: ("--dump"),
+  stdout: "
+    foo:
+        # bar
+        echo bar
+  ",
+}
+
+test! {
+  name: recipe_body_is_comment,
+  justfile: "
+    foo:
+        # bar
+  ",
+  args: ("--dump"),
+  stdout: "
+    foo:
+        # bar
+  ",
+}
+
+test! {
+  name: recipe_several_commands,
+  justfile: "
+    foo:
+        echo bar
+        echo baz
+  ",
+  args: ("--dump"),
+  stdout: "
+    foo:
+        echo bar
+        echo baz
+  ",
+}
+
+test! {
+  name: recipe_quiet,
+  justfile: "
+    @foo:
+        echo bar
+  ",
+  args: ("--dump"),
+  stdout: "
+    @foo:
+        echo bar
+  ",
+}
+
+test! {
+  name: recipe_quiet_command,
+  justfile: "
+    foo:
+        @echo bar
+  ",
+  args: ("--dump"),
+  stdout: "
+    foo:
+        @echo bar
+  ",
+}
+
+test! {
+  name: recipe_quiet_comment,
+  justfile: "
+    foo:
+        @# bar
+  ",
+  args: ("--dump"),
+  stdout: "
+    foo:
+        @# bar
+  ",
+}
+
+test! {
+  name: recipe_ignore_errors,
+  justfile: "
+    foo:
+        -echo foo
+  ",
+  args: ("--dump"),
+  stdout: "
+    foo:
+        -echo foo
+  ",
+}
+
+test! {
+  name: recipe_parameter,
+  justfile: "
+    foo BAR:
+        echo foo
+  ",
+  args: ("--dump"),
+  stdout: "
+    foo BAR:
+        echo foo
+  ",
+}
+
+test! {
+  name: recipe_parameter_default,
+  justfile: "
+    foo BAR='bar':
+        echo foo
+  ",
+  args: ("--dump"),
+  stdout: "
+    foo BAR='bar':
+        echo foo
+  ",
+}
+
+test! {
+  name: recipe_parameter_envar,
+  justfile: "
+    foo $BAR:
+        echo foo
+  ",
+  args: ("--dump"),
+  stdout: "
+    foo $BAR:
+        echo foo
+  ",
+}
+
+test! {
+  name: recipe_parameter_default_envar,
+  justfile: "
+    foo $BAR='foo':
+        echo foo
+  ",
+  args: ("--dump"),
+  stdout: "
+    foo $BAR='foo':
+        echo foo
+  ",
+}
+
+test! {
+  name: recipe_parameter_concat,
+  justfile: "
+    foo BAR=('bar' + 'baz'):
+        echo foo
+  ",
+  args: ("--dump"),
+  stdout: "
+    foo BAR=('bar' + 'baz'):
+        echo foo
+  ",
+}
+
+test! {
+  name: recipe_parameters,
+  justfile: "
+    foo BAR BAZ:
+        echo foo
+  ",
+  args: ("--dump"),
+  stdout: "
+    foo BAR BAZ:
+        echo foo
+  ",
+}
+
+test! {
+  name: recipe_parameters_envar,
+  justfile: "
+    foo $BAR $BAZ:
+        echo foo
+  ",
+  args: ("--dump"),
+  stdout: "
+    foo $BAR $BAZ:
+        echo foo
+  ",
+}
+
+test! {
+  name: recipe_variadic_plus,
+  justfile: "
+    foo +BAR:
+        echo foo
+  ",
+  args: ("--dump"),
+  stdout: "
+    foo +BAR:
+        echo foo
+  ",
+}
+
+test! {
+  name: recipe_variadic_star,
+  justfile: "
+    foo *BAR:
+        echo foo
+  ",
+  args: ("--dump"),
+  stdout: "
+    foo *BAR:
+        echo foo
+  ",
+}
+
+test! {
+  name: recipe_positional_variadic,
+  justfile: "
+    foo BAR *BAZ:
+        echo foo
+  ",
+  args: ("--dump"),
+  stdout: "
+    foo BAR *BAZ:
+        echo foo
+  ",
+}
+
+test! {
+  name: recipe_variadic_default,
+  justfile: "
+    foo +BAR='bar':
+        echo foo
+  ",
+  args: ("--dump"),
+  stdout: "
+    foo +BAR='bar':
+        echo foo
+  ",
+}
+
+test! {
+  name: recipe_parameter_in_body,
+  justfile: "
+    foo BAR:
+        echo {{ BAR }}
+  ",
+  args: ("--dump"),
+  stdout: "
+    foo BAR:
+        echo {{ BAR }}
+  ",
+}
+
+test! {
+  name: recipe_parameter_conditional,
+  justfile: "
+    foo BAR:
+        echo {{ if 'foo' == 'foo' { 'foo' } else { 'bar' } }}
+  ",
+  args: ("--dump"),
+  stdout: "
+    foo BAR:
+        echo {{ if 'foo' == 'foo' { 'foo' } else { 'bar' } }}
+  ",
+}
+
+test! {
+  name: recipe_escaped_braces,
+  justfile: "
+    foo BAR:
+        echo '{{{{BAR}}}}'
+  ",
+  args: ("--dump"),
+  stdout: "
+    foo BAR:
+        echo '{{{{BAR}}}}'
+  ",
+}
+
+test! {
+  name: recipe_assignment_in_body,
+  justfile: "
+    bar := 'bar'
+
+    foo:
+        echo $bar
+  ",
+  args: ("--dump"),
+  stdout: "
+    bar := 'bar'
+
+    foo:
+        echo $bar
+  ",
+}
+
+test! {
+  name: recipe_dependency,
+  justfile: "
+    bar:
+        echo bar
+
+    foo: bar
+        echo foo
+  ",
+  args: ("--dump"),
+  stdout: "
+    bar:
+        echo bar
+
+    foo: bar
+        echo foo
+  ",
+}
+
+test! {
+  name: recipe_dependency_param,
+  justfile: "
+    bar BAR:
+        echo bar
+
+    foo: (bar 'bar')
+        echo foo
+  ",
+  args: ("--dump"),
+  stdout: "
+    bar BAR:
+        echo bar
+
+    foo: (bar 'bar')
+        echo foo
+  ",
+}
+
+test! {
+  name: recipe_dependency_params,
+  justfile: "
+    bar BAR BAZ:
+        echo bar
+
+    foo: (bar 'bar' 'baz')
+        echo foo
+  ",
+  args: ("--dump"),
+  stdout: "
+    bar BAR BAZ:
+        echo bar
+
+    foo: (bar 'bar' 'baz')
+        echo foo
+  ",
+}
+
+test! {
+  name: recipe_dependencies,
+  justfile: "
+    bar:
+        echo bar
+
+    baz:
+        echo baz
+
+    foo: baz bar
+        echo foo
+  ",
+  args: ("--dump"),
+  stdout: "
+    bar:
+        echo bar
+
+    baz:
+        echo baz
+
+    foo: baz bar
+        echo foo
+  ",
+}
+
+test! {
+  name: recipe_dependencies_params,
+  justfile: "
+    bar BAR:
+        echo bar
+
+    baz BAZ:
+        echo baz
+
+    foo: (baz 'baz') (bar 'bar')
+        echo foo
+  ",
+  args: ("--dump"),
+  stdout: "
+    bar BAR:
+        echo bar
+
+    baz BAZ:
+        echo baz
+
+    foo: (baz 'baz') (bar 'bar')
+        echo foo
+  ",
+}
+
+test! {
+  name: set_true_explicit,
+  justfile: "
+    set export := true
+  ",
+  args: ("--dump"),
+  stdout: "
+    set export := true
+  ",
+}
+
+test! {
+  name: set_true_implicit,
+  justfile: "
+    set export
+  ",
+  args: ("--dump"),
+  stdout: "
+    set export := true
+  ",
+}
+
+test! {
+  name: set_false,
+  justfile: "
+    set export := false
+  ",
+  args: ("--dump"),
+  stdout: "
+    set export := false
+  ",
+}
+
+test! {
+  name: set_shell,
+  justfile: r#"
+    set shell := ['sh', "-c"]
+  "#,
+  args: ("--dump"),
+  stdout: r#"
+    set shell := ['sh', "-c"]
+  "#,
+}
+
+test! {
+  name: comment,
+  justfile: "
+    # foo
+  ",
+  args: ("--dump"),
+  stdout: "
+    # foo
+  ",
+}
+
+test! {
+  name: comment_multiline,
+  justfile: "
+    # foo
+    # bar
+  ",
+  args: ("--dump"),
+  stdout: "
+    # foo
+    # bar
+  ",
+}
+
+test! {
+  name: comment_leading,
+  justfile: "
+    # foo
+
+    foo := 'bar'
+  ",
+  args: ("--dump"),
+  stdout: "
+    # foo
+
+    foo := 'bar'
+  ",
+}
+
+test! {
+  name: comment_trailing,
+  justfile: "
+    foo := 'bar'
+
+    # foo
+  ",
+  args: ("--dump"),
+  stdout: "
+    foo := 'bar'
+
+    # foo
+  ",
+}
+
+test! {
+  name: comment_before_recipe,
+  justfile: "
+    # foo
+
+    foo:
+        echo foo
+  ",
+  args: ("--dump"),
+  stdout: "
+    # foo
+
+    foo:
+        echo foo
+  ",
+}
+
+test! {
+  name: comment_before_docstring_recipe,
+  justfile: "
+    # bar
+
+    # foo
+    foo:
+        echo foo
+  ",
+  args: ("--dump"),
+  stdout: "
+    # bar
+
+    # foo
+    foo:
+        echo foo
+  ",
+}
+
+test! {
+  name: group_recipies,
+  justfile: "
+    foo:
+        echo foo
+    bar:
+        echo bar
+  ",
+  args: ("--dump"),
+  stdout: "
+    foo:
+        echo foo
+
+    bar:
+        echo bar
+  ",
+}
+
+test! {
+  name: group_aliases,
+  justfile: "
+    alias f := foo
+
+    alias b := bar
+
+    foo:
+        echo foo
+
+    bar:
+        echo bar
+  ",
+  args: ("--dump"),
+  stdout: "
+    alias f := foo
+    alias b := bar
+
+    foo:
+        echo foo
+
+    bar:
+        echo bar
+  ",
+}
+
+test! {
+  name: group_assignments,
+  justfile: "
+    foo := 'foo'
+    bar := 'bar'
+  ",
+  args: ("--dump"),
+  stdout: "
+    foo := 'foo'
+    bar := 'bar'
+  ",
+}
+
+test! {
+  name: group_sets,
+  justfile: "
+    set export := true
+    set positional-arguments := true
+  ",
+  args: ("--dump"),
+  stdout: "
+    set export := true
+    set positional-arguments := true
+  ",
+}
+
+test! {
+  name: group_comments,
+  justfile: "
+    # foo
+
+    # bar
+  ",
+  args: ("--dump"),
+  stdout: "
+    # foo
+    # bar
+  ",
+}
+
+test! {
+  name: separate_recipes_aliases,
+  justfile: "
+    alias f := foo
+    foo:
+        echo foo
+  ",
+  args: ("--dump"),
+  stdout: "
+    alias f := foo
+
+    foo:
+        echo foo
+  ",
+}
+
+test! {
+  name: no_trailing_newline,
+  justfile: "
+    foo:
+        echo foo",
+  args: ("--dump"),
+  stdout: "
+    foo:
+        echo foo
+  ",
+}
diff --git a/tests/lib.rs b/tests/lib.rs
index d4b62a45..c4eb592d 100644
--- a/tests/lib.rs
+++ b/tests/lib.rs
@@ -14,6 +14,7 @@ mod error_messages;
 mod evaluate;
 mod examples;
 mod export;
+mod fmt;
 mod init;
 mod interrupts;
 mod invocation_directory;
diff --git a/tests/misc.rs b/tests/misc.rs
index 7bbe6d04..e3cea425 100644
--- a/tests/misc.rs
+++ b/tests/misc.rs
@@ -265,7 +265,7 @@ recipe:
   args:     ("--show", "recipe"),
   stdout:   r#"
     recipe:
-        echo {{hello + "bar" + bar}}
+        echo {{ hello + "bar" + bar }}
   "#,
 }
 
diff --git a/tests/test.rs b/tests/test.rs
index d617c2cc..4b708979 100644
--- a/tests/test.rs
+++ b/tests/test.rs
@@ -75,7 +75,7 @@ impl<'a> Test<'a> {
 
     let mut justfile_path = tmp.path().to_path_buf();
     justfile_path.push("justfile");
-    fs::write(justfile_path, justfile).unwrap();
+    fs::write(&justfile_path, justfile).unwrap();
 
     let mut dotenv_path = tmp.path().to_path_buf();
     dotenv_path.push(".env");