diff --git a/GRAMMAR.md b/GRAMMAR.md index c05091c918..93c8c1855a 100644 --- a/GRAMMAR.md +++ b/GRAMMAR.md @@ -84,6 +84,7 @@ setting : 'allow-duplicate-recipes' boolean? | 'fallback' boolean? | 'guards' boolean? | 'ignore-comments' boolean? + | 'indentation' ':=' string | 'lazy' boolean? | 'lists' boolean? | 'minimum-version' ':=' string diff --git a/README.md b/README.md index a40e6a3b4e..f773bebec6 100644 --- a/README.md +++ b/README.md @@ -4502,6 +4502,14 @@ stdout. Note that formatting is not covered by any backwards compatibility guarantee and is subject to change from time to time. +Recipe bodies are indented with four spaces by default. This can be changed +with the `--indentation` command-line option, the `JUST_INDENTATION` +environment variable, or the `indentation` setting: + +```just +set indentation := " " +``` + Invoking `just --fmt --check` runs `--fmt` in check mode. Instead of overwriting the `justfile`, `just` will exit with an exit code of 0 if it is formatted correctly, and will exit with 1 and print a diff if it is not. @@ -4794,6 +4802,7 @@ foo: | `fallback` | boolean | `false` | Search for `justfile` in parent directory if the first recipe on the command line is not found. | | `guards`1.47.0 | boolean | `false` | Enable the `?` guard sigil on recipe lines. See [sigils](#sigils). | | `ignore-comments` | boolean | `false` | Ignore recipe lines beginning with `#`. | +| `indentation`master | string | - | Set recipe body indentation used when formatting with `--fmt` or `--dump`. | | `lazy`1.47.0 | boolean | `false` | Don't evaluate unused variables. | | `lists`1.53.0 | boolean | `false` | Values may be lists of strings instead of strings. Currently unstable. | | `minimum-version`1.55.0 | string | - | Error if `just` is older than `minimum-version`. Accepts a string of the form `MAJOR.MINOR.PATCH`, e.g., `"1.55.0"`. | diff --git a/src/arguments.rs b/src/arguments.rs index 8d4bf78616..d1700d2851 100644 --- a/src/arguments.rs +++ b/src/arguments.rs @@ -186,12 +186,11 @@ pub struct Arguments { )] pub(crate) highlight: bool, #[arg( - default_value = " ", env = "JUST_INDENTATION", help = "Indent recipes bodies with ", long )] - pub(crate) indentation: Indentation, + pub(crate) indentation: Option, #[arg( add = ArgValueCompleter::new(PathCompleter::file()), env = "JUST_JUSTFILE", diff --git a/src/ast.rs b/src/ast.rs index eb63468168..e2a8180c51 100644 --- a/src/ast.rs +++ b/src/ast.rs @@ -13,6 +13,20 @@ pub(crate) struct Ast<'src> { pub(crate) working_directory: PathBuf, } +impl Ast<'_> { + pub(crate) fn indentation(&self) -> Option { + self.items.iter().find_map(|item| { + if let Item::Setting(set) = item + && let Setting::Indentation(_, indentation) = set.value + { + Some(indentation) + } else { + None + } + }) + } +} + impl ColorDisplay for Ast<'_> { fn fmt(&self, f: &mut Formatter, color: Color) -> fmt::Result { let mut newlines = 0; diff --git a/src/color.rs b/src/color.rs index 631b9dc714..b810758616 100644 --- a/src/color.rs +++ b/src/color.rs @@ -175,7 +175,14 @@ impl Color { } } - pub(crate) fn use_color(self, use_color: UseColor) -> Self { + pub(crate) fn with_use_color(self, use_color: UseColor) -> Self { Self { use_color, ..self } } + + pub(crate) fn with_indentation(self, indentation: Indentation) -> Self { + Self { + indentation, + ..self + } + } } diff --git a/src/compile_error.rs b/src/compile_error.rs index a12796814e..8cfa103c87 100644 --- a/src/compile_error.rs +++ b/src/compile_error.rs @@ -278,6 +278,9 @@ impl Display for CompileError<'_> { "shell recipe `{recipe}` has script recipe attribute `{}`", attribute.name(), ), + InvalidIndentation { message } => { + write!(f, "{message}") + } InvalidMinimumVersion { source, version } => { write!( f, @@ -305,12 +308,6 @@ impl Display for CompileError<'_> { f, "justfile requires just {minimum} or later, but using {current}", ), - MinimumVersionExpression => { - write!( - f, - "`minimum-version` setting must be a plain string literal" - ) - } MismatchedClosingDelimiter { open, open_line, @@ -372,6 +369,9 @@ impl Display for CompileError<'_> { f, "recipe `{recipe}` has both `[script]` and `[shell]` attributes" ), + SettingExpression { setting } => { + write!(f, "`{setting}` setting must be a plain string literal") + } ShellExpansion { err } => write!(f, "shell expansion failed: {err}"), ShortOptionWithMultipleCharacters { parameter } => { write!( diff --git a/src/compile_error_kind.rs b/src/compile_error_kind.rs index 041312e2b7..fcb7a300a6 100644 --- a/src/compile_error_kind.rs +++ b/src/compile_error_kind.rs @@ -139,6 +139,9 @@ pub(crate) enum CompileErrorKind<'src> { InvalidEscapeSequence { character: char, }, + InvalidIndentation { + message: &'static str, + }, InvalidMinimumVersion { source: &'static str, version: String, @@ -158,7 +161,6 @@ pub(crate) enum CompileErrorKind<'src> { current: Version, minimum: Version, }, - MinimumVersionExpression, MismatchedClosingDelimiter { close: Delimiter, open: Delimiter, @@ -192,6 +194,9 @@ pub(crate) enum CompileErrorKind<'src> { ScriptAndShellAttribute { recipe: &'src str, }, + SettingExpression { + setting: Keyword, + }, ShellExpansion { err: shellexpand::LookupError, }, diff --git a/src/config.rs b/src/config.rs index f82fea8633..771e5dce23 100644 --- a/src/config.rs +++ b/src/config.rs @@ -18,6 +18,7 @@ pub(crate) struct Config { pub(crate) explain: bool, pub(crate) groups: Vec, pub(crate) highlight: bool, + pub(crate) indentation: Option, pub(crate) invocation_directory: PathBuf, pub(crate) justfile_names: Option>, pub(crate) list_heading: String, @@ -63,6 +64,7 @@ impl Config { explain: false, groups: Vec::new(), highlight: true, + indentation: None, invocation_directory: env::current_dir().context(config_error::CurrentDir)?, justfile_names: None, list_heading: Arguments::DEFAULT_LIST_HEADING.into(), @@ -315,7 +317,7 @@ impl Config { } let unstable = arguments.unstable || subcommand == Subcommand::Summary; - let color = Color::new(arguments.indentation, arguments.color); + let color = Color::new(arguments.indentation.unwrap_or_default(), arguments.color); let invocation_directory = env::current_dir().context(config_error::CurrentDir)?; @@ -338,6 +340,7 @@ impl Config { explain: arguments.explain, groups: arguments.group, highlight: !arguments.no_highlight, + indentation: arguments.indentation, invocation_directory, justfile_names: arguments.justfile_names, list_heading: arguments.list_heading, diff --git a/src/evaluator.rs b/src/evaluator.rs index 73c69c2fb8..343c641687 100644 --- a/src/evaluator.rs +++ b/src/evaluator.rs @@ -107,6 +107,9 @@ impl<'src, 'run> Evaluator<'src, 'run> { Setting::IgnoreComments(value) => { settings.ignore_comments = value; } + Setting::Indentation(_, indentation) => { + settings.indentation = Some(indentation); + } Setting::Lazy(value) => { settings.lazy = value; } diff --git a/src/indentation.rs b/src/indentation.rs index fda2e3481e..53edd01245 100644 --- a/src/indentation.rs +++ b/src/indentation.rs @@ -24,6 +24,15 @@ impl Display for Indentation { } } +impl Serialize for Indentation { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.collect_str(self) + } +} + impl FromStr for Indentation { type Err = &'static str; diff --git a/src/keyword.rs b/src/keyword.rs index 038d4a2d30..5404eef880 100644 --- a/src/keyword.rs +++ b/src/keyword.rs @@ -25,6 +25,7 @@ pub(crate) enum Keyword { If, IgnoreComments, Import, + Indentation, Lazy, Lists, MinimumVersion, diff --git a/src/node.rs b/src/node.rs index 540854d75e..00e18a5e56 100644 --- a/src/node.rs +++ b/src/node.rs @@ -318,11 +318,13 @@ impl<'src> Node<'src> for Set<'src> { Setting::DotenvCommand(value) | Setting::DotenvFilename(value) | Setting::DotenvPath(value) - | Setting::MinimumVersion(value) | Setting::Tempdir(value) | Setting::WorkingDirectory(value) => { set.push_mut(value.tree()); } + Setting::Indentation(value, _) | Setting::MinimumVersion(value) => { + set.push_mut(Tree::string(&value.cooked)); + } Setting::ScriptInterpreter(Interpreter { command, arguments }) | Setting::Shell(Interpreter { command, arguments }) | Setting::WindowsShell(Interpreter { command, arguments }) => { diff --git a/src/parser.rs b/src/parser.rs index eb69576071..50c5938be8 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -1614,15 +1614,37 @@ impl<'run, 'src> Parser<'run, 'src> { Keyword::DotenvCommand => Some(Setting::DotenvCommand(self.parse_expression()?)), Keyword::DotenvFilename => Some(Setting::DotenvFilename(self.parse_expression()?)), Keyword::DotenvPath => Some(Setting::DotenvPath(self.parse_expression()?)), + Keyword::Indentation => { + let expression = self.parse_expression()?; + + let Expression::StringLiteral { string_literal } = expression else { + return Err(name.error(CompileErrorKind::SettingExpression { setting: keyword })); + }; + + if string_literal.expand || string_literal.kind.indented || string_literal.part.is_some() { + return Err(name.error(CompileErrorKind::SettingExpression { setting: keyword })); + } + + let indentation = string_literal + .cooked + .parse::() + .map_err(|message| { + string_literal + .token + .error(CompileErrorKind::InvalidIndentation { message }) + })?; + + Some(Setting::Indentation(string_literal, indentation)) + } Keyword::MinimumVersion => { let expression = self.parse_expression()?; - let Expression::StringLiteral { string_literal } = &expression else { - return Err(name.error(CompileErrorKind::MinimumVersionExpression)); + let Expression::StringLiteral { string_literal } = expression else { + return Err(name.error(CompileErrorKind::SettingExpression { setting: keyword })); }; if string_literal.expand || string_literal.kind.indented || string_literal.part.is_some() { - return Err(name.error(CompileErrorKind::MinimumVersionExpression)); + return Err(name.error(CompileErrorKind::SettingExpression { setting: keyword })); } let minimum = string_literal.cooked.parse::().map_err(|source| { @@ -1643,7 +1665,7 @@ impl<'run, 'src> Parser<'run, 'src> { ); } - Some(Setting::MinimumVersion(expression)) + Some(Setting::MinimumVersion(string_literal)) } Keyword::ScriptInterpreter => Some(Setting::ScriptInterpreter(self.parse_interpreter()?)), Keyword::Shell => Some(Setting::Shell(self.parse_interpreter()?)), diff --git a/src/setting.rs b/src/setting.rs index 65aa666a94..7966a02507 100644 --- a/src/setting.rs +++ b/src/setting.rs @@ -16,9 +16,10 @@ pub(crate) enum Setting<'src> { Fallback(bool), Guards(bool), IgnoreComments(bool), + Indentation(StringLiteral<'src>, Indentation), Lazy(bool), Lists(bool), - MinimumVersion(Expression<'src>), + MinimumVersion(StringLiteral<'src>), NoCd(bool), NoExitMessage(bool), PositionalArguments(bool), @@ -57,9 +58,9 @@ impl<'src> Setting<'src> { Self::DotenvCommand(_value) | Self::DotenvFilename(_value) | Self::DotenvPath(_value) - | Self::MinimumVersion(_value) | Self::Tempdir(_value) | Self::WorkingDirectory(_value) => false, + Self::Indentation(..) | Self::MinimumVersion(_) => false, Self::ScriptInterpreter(_value) | Self::Shell(_value) | Self::WindowsShell(_value) => false, } } @@ -131,11 +132,11 @@ impl Display for Setting<'_> { Self::DotenvCommand(value) | Self::DotenvFilename(value) | Self::DotenvPath(value) - | Self::MinimumVersion(value) | Self::Tempdir(value) | Self::WorkingDirectory(value) => { write!(f, "{value}") } + Self::Indentation(value, _) | Self::MinimumVersion(value) => write!(f, "{value}"), Self::ScriptInterpreter(value) | Self::Shell(value) | Self::WindowsShell(value) => { write!(f, "[{value}]") } diff --git a/src/settings.rs b/src/settings.rs index 6337e75a15..60a36cedb8 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -34,6 +34,7 @@ pub(crate) struct Settings { pub(crate) fallback: bool, pub(crate) guards: bool, pub(crate) ignore_comments: bool, + pub(crate) indentation: Option, pub(crate) lazy: bool, pub(crate) lists: bool, pub(crate) no_cd: bool, diff --git a/src/subcommand.rs b/src/subcommand.rs index 91d7263bb9..5b898b50de 100644 --- a/src/subcommand.rs +++ b/src/subcommand.rs @@ -431,12 +431,22 @@ impl Subcommand { .map_err(|source| Error::DumpJson { source })?; println!(); } - DumpFormat::Just => print!( - "{}", - compilation - .root_ast() - .color_display(config.color.use_color(UseColor::Never)) - ), + DumpFormat::Just => { + print!( + "{}", + compilation.root_ast().color_display( + config + .color + .with_use_color(UseColor::Never) + .with_indentation( + config + .indentation + .or(compilation.justfile.settings.indentation) + .unwrap_or_default() + ) + ) + ); + } } Ok(()) } @@ -476,7 +486,12 @@ impl Subcommand { )?; let formatted = ast - .color_display(config.color.use_color(UseColor::Never)) + .color_display( + config + .color + .with_use_color(UseColor::Never) + .with_indentation(config.indentation.or(ast.indentation()).unwrap_or_default()), + ) .to_string(); if config.check { @@ -516,7 +531,7 @@ impl Subcommand { })?; if config.verbosity.loud() { - eprintln!("Wrote justfile to `{}`", search.justfile.display()); + eprintln!("wrote justfile to `{}`", search.justfile.display()); } } @@ -541,7 +556,7 @@ impl Subcommand { } if config.verbosity.loud() { - eprintln!("Wrote justfile to `{}`", search.justfile.display()); + eprintln!("wrote justfile to `{}`", search.justfile.display()); } Ok(()) diff --git a/tests/ceiling.rs b/tests/ceiling.rs index 5e6999b113..aec731ed60 100644 --- a/tests/ceiling.rs +++ b/tests/ceiling.rs @@ -76,9 +76,9 @@ fn justfile_init_search_stops_at_ceiling_dir() { .current_dir("foo/bar") .args(["--init", "--ceiling", ceiling.to_str().unwrap()]) .stderr_regex(if cfg!(windows) { - r"Wrote justfile to `.*\\foo\\bar\\justfile`\n" + r"wrote justfile to `.*\\foo\\bar\\justfile`\n" } else { - "Wrote justfile to `.*/foo/bar/justfile`\n" + "wrote justfile to `.*/foo/bar/justfile`\n" }) .success(); diff --git a/tests/format.rs b/tests/format.rs index 7e0f1f864c..412f20aa96 100644 --- a/tests/format.rs +++ b/tests/format.rs @@ -1577,6 +1577,159 @@ fn indentation_env() { .success(); } +#[test] +fn indentation_setting_dump() { + Test::new() + .arg("--dump") + .justfile( + " + set indentation := ' ' + + foo: + echo bar + ", + ) + .stdout( + " + set indentation := ' ' + + foo: + echo bar + ", + ) + .success(); +} + +#[test] +fn indentation_setting_format() { + let output = Test::new() + .arg("--fmt") + .justfile( + " + set indentation := ' ' + + foo: + echo bar + ", + ) + .stderr_regex("wrote justfile to `.*justfile`\n") + .success(); + + assert_eq!( + fs::read_to_string(output.tempdir.path().join("justfile")).unwrap(), + "set indentation := ' ' + +foo: + echo bar +", + ); +} + +#[test] +fn indentation_flag_overrides_setting_dump() { + Test::new() + .args(["--dump", "--indentation", " "]) + .justfile( + " + set indentation := ' ' + + foo: + echo bar + ", + ) + .stdout( + " + set indentation := ' ' + + foo: + echo bar + ", + ) + .success(); +} + +#[test] +fn indentation_flag_overrides_setting_format() { + let output = Test::new() + .args(["--fmt", "--indentation", " "]) + .justfile( + " + set indentation := ' ' + + foo: + echo bar + ", + ) + .stderr_regex("wrote justfile to `.*justfile`\n") + .success(); + + assert_eq!( + fs::read_to_string(output.tempdir.path().join("justfile")).unwrap(), + "set indentation := ' ' + +foo: + echo bar +", + ); +} + +#[test] +fn indentation_setting_must_be_string_literal() { + Test::new() + .justfile("set indentation := (' ' + ' ')") + .stderr( + " + error: `indentation` setting must be a plain string literal + ——▶ justfile:1:5 + │ + 1 │ set indentation := (' ' + ' ') + │ ^^^^^^^^^^^ + ", + ) + .failure(); +} + +#[test] +fn indentation_setting_invalid_values() { + #[track_caller] + fn case(justfile: &str, stderr: &str) { + Test::new().justfile(justfile).stderr(stderr).failure(); + } + + case( + "set indentation := ''", + " + error: indentation must not be empty + ——▶ justfile:1:20 + │ + 1 │ set indentation := '' + │ ^^ + ", + ); + + case( + "set indentation := 'x'", + " + error: indentation must be spaces or tabs + ——▶ justfile:1:20 + │ + 1 │ set indentation := 'x' + │ ^^^ + ", + ); + + case( + "set indentation := \" \\t\"", + " + error: indentation may not be mixed + ——▶ justfile:1:20 + │ + 1 │ set indentation := \" \\t\" + │ ^^^^^ + ", + ); +} + #[test] fn multi_line_comments_before_recipes_are_not_broken_up() { assert_dump( diff --git a/tests/init.rs b/tests/init.rs index d2e8f4ad87..b6b39a21b0 100644 --- a/tests/init.rs +++ b/tests/init.rs @@ -22,7 +22,7 @@ fn current_dir() { fn exists() { Test::new() .arg("--init") - .stderr_regex("Wrote justfile to `.*`\n") + .stderr_regex("wrote justfile to `.*`\n") .success() .test() .arg("--init") @@ -55,7 +55,7 @@ fn invocation_directory() { let justfile_path = test.justfile_path(); let _tmp = test - .stderr_regex("Wrote justfile to `.*`\n") + .stderr_regex("wrote justfile to `.*`\n") .arg("--init") .success(); @@ -170,7 +170,7 @@ fn justfile_name_from_invocation_directory() { Test::new() .create_dir(".git") .args(["--init", "--justfile-name", "foo"]) - .stderr_regex("Wrote justfile to `.*`\n") + .stderr_regex("wrote justfile to `.*`\n") .expect_file("foo", INIT_JUSTFILE) .success(); } @@ -180,7 +180,7 @@ fn justfile_name_from_search_directory() { Test::new() .create_dir("sub/.git") .args(["--init", "--justfile-name", "foo", "sub/"]) - .stderr_regex("Wrote justfile to `.*`\n") + .stderr_regex("wrote justfile to `.*`\n") .expect_file("sub/foo", INIT_JUSTFILE) .success(); } @@ -189,7 +189,7 @@ fn justfile_name_from_search_directory() { fn fmt_compatibility() { Test::new() .arg("--init") - .stderr_regex("Wrote justfile to `.*`\n") + .stderr_regex("wrote justfile to `.*`\n") .success() .test() .arg("--unstable") diff --git a/tests/json.rs b/tests/json.rs index b9c43d5cd7..106366aa81 100644 --- a/tests/json.rs +++ b/tests/json.rs @@ -107,6 +107,7 @@ struct Settings<'a> { fallback: bool, guards: bool, ignore_comments: bool, + indentation: Option<&'a str>, lazy: bool, lists: bool, no_cd: bool, @@ -702,6 +703,7 @@ fn settings() { set export set fallback set ignore-comments + set indentation := \" \" set positional-arguments set quiet set shell := ['a', 'b', 'c'] @@ -731,6 +733,7 @@ fn settings() { export: true, fallback: true, ignore_comments: true, + indentation: Some(" "), positional_arguments: true, quiet: true, shell: Some(Interpreter { diff --git a/tests/unstable.rs b/tests/unstable.rs index e39953824f..f40292b924 100644 --- a/tests/unstable.rs +++ b/tests/unstable.rs @@ -7,7 +7,7 @@ fn set_unstable_true_with_env_var() { .justfile("# hello") .args(["--fmt"]) .env("JUST_UNSTABLE", val) - .stderr_regex("Wrote justfile to `.*`\n") + .stderr_regex("wrote justfile to `.*`\n") .success(); } } @@ -38,7 +38,7 @@ fn set_unstable_with_setting() { Test::new() .justfile("set unstable") .arg("--fmt") - .stderr_regex("Wrote justfile to .*") + .stderr_regex("wrote justfile to .*") .success(); }