From 481454b944677bc9bf235d6267e4faf4f89e165f Mon Sep 17 00:00:00 2001 From: David Sherret Date: Fri, 21 Aug 2026 12:44:42 -0400 Subject: [PATCH 1/3] feat: add indentStages config --- deployment/schema.json | 10 ++ src/configuration/builder.rs | 20 ++- src/configuration/configuration.rs | 4 + src/configuration/resolve_config.rs | 7 + src/generation/context.rs | 4 + src/generation/generate.rs | 104 +++++++++++- tests/specs/IndentStages/IndentStages_All.txt | 159 ++++++++++++++++++ .../IndentStages/IndentStages_IndentWidth.txt | 16 ++ 8 files changed, 313 insertions(+), 11 deletions(-) create mode 100644 tests/specs/IndentStages/IndentStages_All.txt create mode 100644 tests/specs/IndentStages/IndentStages_IndentWidth.txt diff --git a/deployment/schema.json b/deployment/schema.json index 387300e..96ee676 100644 --- a/deployment/schema.json +++ b/deployment/schema.json @@ -39,6 +39,16 @@ "description": "Whether to always break a HEALTHCHECK command onto its own continuation line when the instruction has options, even if it would fit on one line.", "default": false, "type": "boolean" + }, + "indentStages": { + "description": "Whether to indent the instructions that follow a FROM instruction.", + "default": false, + "type": "boolean" + }, + "indentWidth": { + "description": "The number of spaces used for a level of indentation.", + "default": 2, + "type": "number" } } } diff --git a/src/configuration/builder.rs b/src/configuration/builder.rs index 16b49fb..1af2d22 100644 --- a/src/configuration/builder.rs +++ b/src/configuration/builder.rs @@ -71,6 +71,18 @@ impl ConfigurationBuilder { self.insert("healthcheckCmdNewLine", value.into()) } + /// Whether to indent the instructions that follow a `FROM` instruction. + /// Default: `false` + pub fn indent_stages(&mut self, value: bool) -> &mut Self { + self.insert("indentStages", value.into()) + } + + /// The number of spaces used for a level of indentation. + /// Default: 2 + pub fn indent_width(&mut self, value: u8) -> &mut Self { + self.insert("indentWidth", (value as i32).into()) + } + #[cfg(test)] pub(super) fn get_inner_config(&self) -> ConfigKeyMap { self.config.clone() @@ -95,10 +107,12 @@ mod tests { config .new_line_kind(NewLineKind::CarriageReturnLineFeed) .line_width(90) - .healthcheck_cmd_new_line(true); + .healthcheck_cmd_new_line(true) + .indent_stages(true) + .indent_width(4); let inner_config = config.get_inner_config(); - assert_eq!(inner_config.len(), 3); + assert_eq!(inner_config.len(), 5); let diagnostics = resolve_config(inner_config, &Default::default()).diagnostics; assert_eq!(diagnostics.len(), 0); } @@ -108,12 +122,14 @@ mod tests { let mut global_config = ConfigKeyMap::new(); global_config.insert(String::from("lineWidth"), 90.into()); global_config.insert(String::from("newLineKind"), "crlf".into()); + global_config.insert(String::from("indentWidth"), 4.into()); global_config.insert(String::from("useTabs"), true.into()); let global_config = resolve_global_config(&mut global_config).config; let mut config_builder = ConfigurationBuilder::new(); let config = config_builder.global_config(global_config).build(); assert_eq!(config.line_width, 90); assert_eq!(config.new_line_kind, NewLineKind::CarriageReturnLineFeed); + assert_eq!(config.indent_width, 4); } #[test] diff --git a/src/configuration/configuration.rs b/src/configuration/configuration.rs index 3941244..a109358 100644 --- a/src/configuration/configuration.rs +++ b/src/configuration/configuration.rs @@ -10,4 +10,8 @@ pub struct Configuration { /// Whether to always break a `HEALTHCHECK` command onto its own continuation /// line when the instruction has options, even if it would fit on one line. pub healthcheck_cmd_new_line: bool, + /// Whether to indent the instructions that follow a `FROM` instruction. + pub indent_stages: bool, + /// The number of spaces used for a level of indentation. + pub indent_width: u8, } diff --git a/src/configuration/resolve_config.rs b/src/configuration/resolve_config.rs index 3fb417d..1ff02f9 100644 --- a/src/configuration/resolve_config.rs +++ b/src/configuration/resolve_config.rs @@ -41,6 +41,13 @@ pub fn resolve_config(config: ConfigKeyMap, global_config: &GlobalConfiguration) &mut diagnostics, ), healthcheck_cmd_new_line: get_value(&mut config, "healthcheckCmdNewLine", false, &mut diagnostics), + indent_stages: get_value(&mut config, "indentStages", false, &mut diagnostics), + indent_width: get_value( + &mut config, + "indentWidth", + global_config.indent_width.unwrap_or(RECOMMENDED_GLOBAL_CONFIGURATION.indent_width), + &mut diagnostics, + ), }; diagnostics.extend(get_unknown_property_diagnostics(config)); diff --git a/src/generation/context.rs b/src/generation/context.rs index 7533c5a..b1f746e 100644 --- a/src/generation/context.rs +++ b/src/generation/context.rs @@ -22,6 +22,9 @@ pub struct Context<'a> { /// the quote currently open while collapsing shell whitespace (carried across /// the breakable string's components), or `None` when outside a quote pub shell_quote: Option, + /// the amount of leading whitespace to strip from the continuation lines of + /// the current instruction so the printer can re-indent them + pub dedent_width: usize, } impl<'a> Context<'a> { @@ -36,6 +39,7 @@ impl<'a> Context<'a> { gen_string_content: false, collapse_shell_ws: false, shell_quote: None, + dedent_width: 0, } } diff --git a/src/generation/generate.rs b/src/generation/generate.rs index ca3c07e..5b17ee5 100644 --- a/src/generation/generate.rs +++ b/src/generation/generate.rs @@ -12,26 +12,101 @@ pub fn generate(file: &Dockerfile, text: &str, config: &Configuration) -> PrintI let mut context = Context::new(text, file, config); let mut items = PrintItems::new(); let top_level_nodes = context.gen_nodes_with_comments(0, text.len(), true, file.instructions.iter().map(|i| i.into())); + let is_stage_body = resolve_stage_bodies(&top_level_nodes, text, config); for (i, node) in top_level_nodes.iter().enumerate() { + // a continuation line keeps the indentation it had in the source, so strip + // the instruction's own indentation from it and let the printer add back + // the indentation of where the instruction now sits + context.dedent_width = if config.indent_stages { + line_indent_width(text, node.span().start) + } else { + 0 + }; let node_items = gen_node(node.clone(), &mut context); // safety net: never drop a comment. some instructions discard comments that // follow a line continuation (the parser's arg_ws consumes them); recover // any that weren't emitted and place them just before the instruction. - items.extend(recover_dropped_comments(node, &mut context)); - items.extend(node_items); + let mut instruction_items = recover_dropped_comments(node, &mut context); + instruction_items.extend(node_items); + if is_stage_body[i] { + instruction_items = ir_helpers::with_indent_times(instruction_items, stage_indent_width(config)); + } + items.extend(instruction_items); items.push_signal(Signal::NewLine); - if let Some(next_node) = top_level_nodes.get(i + 1) { - let text_between = &text[node.span().end..next_node.span().start]; - if text_between.chars().filter(|c| *c == '\n').count() > 1 { - items.push_signal(Signal::NewLine); - } + if let Some(next_node) = top_level_nodes.get(i + 1) + && has_blank_line_between(node, next_node, text) + { + items.push_signal(Signal::NewLine); } } items } +/// Determines which top-level nodes make up the body of a build stage and so +/// get indented when `indentStages` is enabled. That's everything following a +/// `FROM` instruction except the comments directly above the next stage, which +/// stay at the outer level with the `FROM` they document. +fn resolve_stage_bodies(nodes: &[Node], text: &str, config: &Configuration) -> Vec { + let mut result = vec![false; nodes.len()]; + if !config.indent_stages { + return result; + } + + let mut is_in_stage = false; + for (i, node) in nodes.iter().enumerate() { + if matches!(node, Node::From(_)) { + is_in_stage = true; + } else { + result[i] = is_in_stage; + } + } + + for i in 0..nodes.len() { + if matches!(nodes[i], Node::From(_)) { + for j in (0..i).rev() { + if !nodes[j].is_comment() || has_blank_line_between(&nodes[j], &nodes[j + 1], text) { + break; + } + result[j] = false; + } + } + } + + result +} + +fn has_blank_line_between(node: &Node, next_node: &Node, text: &str) -> bool { + let text_between = &text[node.span().end..next_node.span().start]; + text_between.chars().filter(|c| *c == '\n').count() > 1 +} + +/// The number of spaces a stage's instructions are indented by. The printer +/// counts indentation in levels of a single space (see the print options) and +/// stores the level in a `u8`, so this is capped well below that limit to leave +/// room for the alignment indentation used within an instruction. +fn stage_indent_width(config: &Configuration) -> u32 { + const MAX: u32 = 200; + (config.indent_width as u32).min(MAX) +} + +/// The width of the indentation of the line the given position is on. +fn line_indent_width(text: &str, pos: usize) -> usize { + let line_start = text[..pos].rfind('\n').map(|i| i + 1).unwrap_or(0); + text[line_start..pos].chars().take_while(is_indent_char).count() +} + +/// Removes up to `width` leading spaces or tabs. +fn dedent(text: &str, width: usize) -> &str { + let count = text.chars().take(width).take_while(is_indent_char).count(); + &text[count..] +} + +fn is_indent_char(c: &char) -> bool { + matches!(*c, ' ' | '\t') +} + fn gen_node<'a>(node: Node<'a>, context: &mut Context<'a>) -> PrintItems { let mut items = PrintItems::new(); @@ -357,7 +432,11 @@ fn gen_grouped_values(values: Vec<(PrintItems, usize)>, indent_width: u32, force // indent level: a level would also re-indent any newline inside the // value (e.g. a CMD that itself continues) on every format pass if i > 0 { - items.push_condition(conditions::if_true("continuationIndent", is_multiline.create_resolver(), gen_from_raw_string(&indent_text))); + items.push_condition(conditions::if_true( + "continuationIndent", + is_multiline.create_resolver(), + gen_from_raw_string(&indent_text), + )); } items.extend(value_items); if i < count - 1 { @@ -461,7 +540,7 @@ fn gen_breakable_string<'a>(node: &'a BreakableString, context: &mut Context<'a> // comments lose their leading whitespace when parsed, so align them // with the surrounding arguments by reusing their indentation if matches!(component, BreakableStringComponent::Comment(_)) { - let indentation = comment_indentation(&node.components, i); + let indentation = dedent(comment_indentation(&node.components, i), context.dedent_width); if !indentation.is_empty() { // via gen_from_raw_string so a tab indent becomes a Tab signal items.extend(gen_from_raw_string(indentation)); @@ -527,6 +606,13 @@ fn gen_string<'a>(node: &'a SpannedString, context: &mut Context<'a>) -> PrintIt true }; let raw = context.span_text(&node.span); + // strip the instruction's source indentation from a continued line so that + // the printer re-indents it (whitespace within a quote is content, so keep it) + let raw = if should_trim || context.shell_quote.is_some() { + raw + } else { + dedent(raw, context.dedent_width) + }; if context.collapse_shell_ws { // run the quote-aware collapse on the raw text so significant whitespace diff --git a/tests/specs/IndentStages/IndentStages_All.txt b/tests/specs/IndentStages/IndentStages_All.txt new file mode 100644 index 0000000..f771aff --- /dev/null +++ b/tests/specs/IndentStages/IndentStages_All.txt @@ -0,0 +1,159 @@ +~~ indentStages: true ~~ +== should indent the instructions in a stage == +FROM node AS base +WORKDIR /app + +FROM base AS runner +USER node +EXPOSE 8000 +CMD ["node", "server.js"] + +[expect] +FROM node AS base + WORKDIR /app + +FROM base AS runner + USER node + EXPOSE 8000 + CMD ["node", "server.js"] + +== should keep the instructions before the first stage at the outer level == +# syntax=docker/dockerfile:1 +ARG NODE_VERSION=20 + +FROM node:${NODE_VERSION} +RUN echo hi + +[expect] +# syntax=docker/dockerfile:1 +ARG NODE_VERSION=20 + +FROM node:${NODE_VERSION} + RUN echo hi + +== should re-indent an over indented stage == +FROM node AS base + WORKDIR /app + RUN echo hi + +[expect] +FROM node AS base + WORKDIR /app + RUN echo hi + +== should keep the relative indentation of continuation lines == +FROM alpine +RUN foo \ + bar \ + baz + +[expect] +FROM alpine + RUN foo \ + bar \ + baz + +== should keep the relative indentation of continuation lines when re-indenting == +FROM alpine + RUN foo \ + bar \ + baz + ENV A=1 \ + B=2 + +[expect] +FROM alpine + RUN foo \ + bar \ + baz + ENV A=1 \ + B=2 + +== should indent a comment within a stage == +FROM alpine +# a comment +RUN echo hi +# a trailing comment + +[expect] +FROM alpine + # a comment + RUN echo hi + # a trailing comment + +== should keep a comment directly above a stage at the outer level == +FROM alpine AS build +RUN echo hi + +# the runner stage +# spanning two lines +FROM alpine AS runner +RUN echo bye + +[expect] +FROM alpine AS build + RUN echo hi + +# the runner stage +# spanning two lines +FROM alpine AS runner + RUN echo bye + +== should indent a comment separated from the next stage by a blank line == +FROM alpine AS build +RUN echo hi +# a note about the build stage + +FROM alpine AS runner +RUN echo bye + +[expect] +FROM alpine AS build + RUN echo hi + # a note about the build stage + +FROM alpine AS runner + RUN echo bye + +== should indent a comment within a continued instruction == +FROM alpine +RUN foo \ + # a comment in the middle + bar + +[expect] +FROM alpine + RUN foo \ + # a comment in the middle + bar + +== should keep a heredoc body verbatim == +FROM alpine +COPY < Date: Fri, 21 Aug 2026 12:45:30 -0400 Subject: [PATCH 2/3] update --- src/generation/context.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/generation/context.rs b/src/generation/context.rs index b1f746e..4729a3d 100644 --- a/src/generation/context.rs +++ b/src/generation/context.rs @@ -16,13 +16,13 @@ pub struct Context<'a> { current_node: Option>, parent_stack: Vec>, pub gen_string_content: bool, - /// whether the current breakable string is shell content whose insignificant + /// Whether the current breakable string is shell content whose insignificant /// whitespace runs should be collapsed pub collapse_shell_ws: bool, - /// the quote currently open while collapsing shell whitespace (carried across + /// The quote currently open while collapsing shell whitespace (carried across /// the breakable string's components), or `None` when outside a quote pub shell_quote: Option, - /// the amount of leading whitespace to strip from the continuation lines of + /// The amount of leading whitespace to strip from the continuation lines of /// the current instruction so the printer can re-indent them pub dedent_width: usize, } From 94bb1298b4fb11dc03e5adb4b479acf2da17671d Mon Sep 17 00:00:00 2001 From: David Sherret Date: Fri, 21 Aug 2026 13:13:49 -0400 Subject: [PATCH 3/3] updates --- Cargo.lock | 74 +--- Cargo.toml | 2 +- deployment/schema.json | 4 +- src/configuration/builder.rs | 19 +- src/configuration/configuration.rs | 7 +- src/format_text.rs | 37 ++ src/generation/generate.rs | 75 ++-- src/generation/helpers.rs | 4 + tests/specs/IndentStages/IndentStages_All.txt | 236 ++++++++++++ .../IndentStages/IndentStages_Comments.txt | 337 ++++++++++++++++++ .../IndentStages/IndentStages_Disabled.txt | 38 ++ .../IndentStages_HealthcheckCmdNewLine.txt | 18 + .../IndentStages/IndentStages_LineWidth.txt | 19 + .../IndentStages_LineWidthDisabled.txt | 8 + .../specs/IndentStages/IndentStages_Tabs.txt | 32 ++ 15 files changed, 807 insertions(+), 103 deletions(-) create mode 100644 tests/specs/IndentStages/IndentStages_Comments.txt create mode 100644 tests/specs/IndentStages/IndentStages_Disabled.txt create mode 100644 tests/specs/IndentStages/IndentStages_HealthcheckCmdNewLine.txt create mode 100644 tests/specs/IndentStages/IndentStages_LineWidth.txt create mode 100644 tests/specs/IndentStages/IndentStages_LineWidthDisabled.txt create mode 100644 tests/specs/IndentStages/IndentStages_Tabs.txt diff --git a/Cargo.lock b/Cargo.lock index 965514d..cadc3e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -53,7 +53,7 @@ dependencies = [ "encode_unicode", "libc", "unicode-width", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -102,9 +102,9 @@ dependencies = [ [[package]] name = "dprint-core" -version = "0.68.1" +version = "0.69.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8698cce2d4defd77683dd360c67351643523c4f87ad177b6574978d0f05e4836" +checksum = "7554bfc77a03c35c5581d5f390b7f102c501e37841f7f43d29dd54a0b3c2284d" dependencies = [ "bumpalo", "hashbrown 0.15.5", @@ -494,7 +494,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys", ] [[package]] @@ -503,15 +503,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -521,63 +512,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a18201040b24831fbb9e4eb208f8892e1f50a37feb53cc7ff887feb8f50e7cd" -dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7764e35d4db8a7921e09562a0304bf2f93e0a51bfccee0bd0bb0b666b015ea" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbaa0368d4f1d2aaefc55b6fcfee13f41544ddf36801e793edbbfd7d7df075ef" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a28637cb1fa3560a16915793afb20081aba2c92ee8af57b4d5f28e4b3e7df313" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffe5e8e31046ce6230cc7215707b816e339ff4d4d67c65dffa206fd0f7aa7b9a" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d6fa32db2bc4a2f5abeacf2b69f7992cd09dca97498da74a151a3132c26befd" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a657e1e9d3f514745a572a6846d3c7aa7dbe1658c056ed9c3344c4109a6949e" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04" - [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index 8adfd65..b8fb64c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,7 +25,7 @@ wasm = ["serde_json", "dprint-core/wasm"] tracing = ["dprint-core/tracing"] [dependencies] -dprint-core = { version = "0.68.1", features = ["formatting"], default-features = false } +dprint-core = { version = "0.69.0", features = ["formatting"], default-features = false } dprint-core-macros = "0.1.0" monch = "0.6.0" serde = { version = "1.0.144", features = ["derive"] } diff --git a/deployment/schema.json b/deployment/schema.json index 96ee676..dd7e341 100644 --- a/deployment/schema.json +++ b/deployment/schema.json @@ -41,12 +41,12 @@ "type": "boolean" }, "indentStages": { - "description": "Whether to indent the instructions that follow a FROM instruction.", + "description": "Whether to indent the body of each build stage—the instructions and comments that follow a FROM—by indentWidth spaces. The indentation is always spaces; useTabs is not honored.", "default": false, "type": "boolean" }, "indentWidth": { - "description": "The number of spaces used for a level of indentation.", + "description": "The number of spaces for an indent. Only used when indentStages is enabled.", "default": 2, "type": "number" } diff --git a/src/configuration/builder.rs b/src/configuration/builder.rs index 1af2d22..b131cb6 100644 --- a/src/configuration/builder.rs +++ b/src/configuration/builder.rs @@ -71,13 +71,16 @@ impl ConfigurationBuilder { self.insert("healthcheckCmdNewLine", value.into()) } - /// Whether to indent the instructions that follow a `FROM` instruction. + /// Whether to indent the body of each build stage - the instructions and + /// comments that follow a `FROM` - by `indent_width` spaces. The indentation + /// is always spaces; `useTabs` is not honored. /// Default: `false` pub fn indent_stages(&mut self, value: bool) -> &mut Self { self.insert("indentStages", value.into()) } - /// The number of spaces used for a level of indentation. + /// The number of spaces for an indent. Only used when `indent_stages` is + /// enabled. /// Default: 2 pub fn indent_width(&mut self, value: u8) -> &mut Self { self.insert("indentWidth", (value as i32).into()) @@ -138,5 +141,17 @@ mod tests { let mut config_builder = ConfigurationBuilder::new(); let config = config_builder.global_config(global_config).build(); assert_eq!(config.new_line_kind, NewLineKind::LineFeed); + assert_eq!(config.indent_width, 2); + assert!(!config.indent_stages); + } + + #[test] + fn override_global_indent_width() { + let mut global_config = ConfigKeyMap::new(); + global_config.insert(String::from("indentWidth"), 4.into()); + let global_config = resolve_global_config(&mut global_config).config; + let mut config_builder = ConfigurationBuilder::new(); + let config = config_builder.global_config(global_config).indent_width(8).build(); + assert_eq!(config.indent_width, 8); } } diff --git a/src/configuration/configuration.rs b/src/configuration/configuration.rs index a109358..56dd967 100644 --- a/src/configuration/configuration.rs +++ b/src/configuration/configuration.rs @@ -10,8 +10,11 @@ pub struct Configuration { /// Whether to always break a `HEALTHCHECK` command onto its own continuation /// line when the instruction has options, even if it would fit on one line. pub healthcheck_cmd_new_line: bool, - /// Whether to indent the instructions that follow a `FROM` instruction. + /// Whether to indent the body of each build stage - the instructions and + /// comments that follow a `FROM` - by `indent_width` spaces. The indentation + /// is always spaces; `useTabs` is not honored. pub indent_stages: bool, - /// The number of spaces used for a level of indentation. + /// The number of spaces for an indent. Only used when `indent_stages` is + /// enabled. pub indent_width: u8, } diff --git a/src/format_text.rs b/src/format_text.rs index 330ab77..ba3a92c 100644 --- a/src/format_text.rs +++ b/src/format_text.rs @@ -64,6 +64,43 @@ mod test { } } + #[test] + fn indents_stages_with_carriage_returns() { + // a `\r` is never part of the indentation and never reaches the dedent + let text = format_with( + "FROM alpine\r\n RUN foo \\\r\n bar\r\n", + crate::configuration::ConfigurationBuilder::new().indent_stages(true), + ); + assert_eq!(text, "FROM alpine\n RUN foo \\\n bar\n"); + } + + #[test] + fn indent_width_of_zero_does_not_indent_stages() { + let text = format_with( + "FROM alpine\nRUN echo hi\n", + crate::configuration::ConfigurationBuilder::new().indent_stages(true).indent_width(0), + ); + assert_eq!(text, "FROM alpine\nRUN echo hi\n"); + } + + #[test] + fn huge_indent_width_is_capped_instead_of_overflowing_the_printer() { + // the printer stores its indent level in a `u8`, so the width is capped + let text = format_with( + "FROM alpine\nENV A=1 \\\n B=2\n", + crate::configuration::ConfigurationBuilder::new().indent_stages(true).indent_width(255), + ); + let indent = " ".repeat(200); + assert_eq!(text, format!("FROM alpine\n{0}ENV A=1 \\\n{0} B=2\n", indent)); + } + + /// Formats the text, resolving the "already formatted" result to the input. + fn format_with(text: &str, builder: &mut crate::configuration::ConfigurationBuilder) -> String { + format_text(&std::path::PathBuf::from("Dockerfile"), text, &builder.build()) + .unwrap() + .unwrap_or_else(|| text.to_string()) + } + #[test] fn comment_with_interior_tab_does_not_panic() { // a tab inside a comment must be emitted as a tab signal, not a raw tab diff --git a/src/generation/generate.rs b/src/generation/generate.rs index 5b17ee5..d6a6dd0 100644 --- a/src/generation/generate.rs +++ b/src/generation/generate.rs @@ -13,11 +13,13 @@ pub fn generate(file: &Dockerfile, text: &str, config: &Configuration) -> PrintI let mut items = PrintItems::new(); let top_level_nodes = context.gen_nodes_with_comments(0, text.len(), true, file.instructions.iter().map(|i| i.into())); let is_stage_body = resolve_stage_bodies(&top_level_nodes, text, config); + let stage_indent_width = stage_indent_width(config); for (i, node) in top_level_nodes.iter().enumerate() { - // a continuation line keeps the indentation it had in the source, so strip - // the instruction's own indentation from it and let the printer add back - // the indentation of where the instruction now sits + // a continuation line keeps the indentation it had in the source, so when + // indenting stages moves the instruction, strip the instruction's own + // indentation from it and let the printer add back the indentation of + // where the instruction now sits context.dedent_width = if config.indent_stages { line_indent_width(text, node.span().start) } else { @@ -30,7 +32,7 @@ pub fn generate(file: &Dockerfile, text: &str, config: &Configuration) -> PrintI let mut instruction_items = recover_dropped_comments(node, &mut context); instruction_items.extend(node_items); if is_stage_body[i] { - instruction_items = ir_helpers::with_indent_times(instruction_items, stage_indent_width(config)); + instruction_items = ir_helpers::with_indent_times(instruction_items, stage_indent_width); } items.extend(instruction_items); items.push_signal(Signal::NewLine); @@ -49,32 +51,35 @@ pub fn generate(file: &Dockerfile, text: &str, config: &Configuration) -> PrintI /// `FROM` instruction except the comments directly above the next stage, which /// stay at the outer level with the `FROM` they document. fn resolve_stage_bodies(nodes: &[Node], text: &str, config: &Configuration) -> Vec { - let mut result = vec![false; nodes.len()]; + let mut is_stage_body = vec![false; nodes.len()]; if !config.indent_stages { - return result; + return is_stage_body; } + // everything after a `FROM` belongs to that stage let mut is_in_stage = false; for (i, node) in nodes.iter().enumerate() { - if matches!(node, Node::From(_)) { + if node.is_from() { is_in_stage = true; } else { - result[i] = is_in_stage; + is_stage_body[i] = is_in_stage; } } + // ...except the run of comments written directly above the next `FROM`, + // which document it and stay at the outer level with it for i in 0..nodes.len() { - if matches!(nodes[i], Node::From(_)) { + if nodes[i].is_from() { for j in (0..i).rev() { if !nodes[j].is_comment() || has_blank_line_between(&nodes[j], &nodes[j + 1], text) { break; } - result[j] = false; + is_stage_body[j] = false; } } } - result + is_stage_body } fn has_blank_line_between(node: &Node, next_node: &Node, text: &str) -> bool { @@ -87,24 +92,14 @@ fn has_blank_line_between(node: &Node, next_node: &Node, text: &str) -> bool { /// stores the level in a `u8`, so this is capped well below that limit to leave /// room for the alignment indentation used within an instruction. fn stage_indent_width(config: &Configuration) -> u32 { - const MAX: u32 = 200; - (config.indent_width as u32).min(MAX) + const MAX_LEVELS: u32 = 200; + (config.indent_width as u32).min(MAX_LEVELS) } /// The width of the indentation of the line the given position is on. fn line_indent_width(text: &str, pos: usize) -> usize { let line_start = text[..pos].rfind('\n').map(|i| i + 1).unwrap_or(0); - text[line_start..pos].chars().take_while(is_indent_char).count() -} - -/// Removes up to `width` leading spaces or tabs. -fn dedent(text: &str, width: usize) -> &str { - let count = text.chars().take(width).take_while(is_indent_char).count(); - &text[count..] -} - -fn is_indent_char(c: &char) -> bool { - matches!(*c, ' ' | '\t') + text[line_start..pos].bytes().take_while(|b| matches!(b, b' ' | b'\t')).count() } fn gen_node<'a>(node: Node<'a>, context: &mut Context<'a>) -> PrintItems { @@ -312,6 +307,7 @@ fn gen_multi_line_items<'a>(nodes: Vec>, indent_width: u32, context: &m }), allow_inline_multi_line: false, allow_inline_single_line: false, + is_known_multi_line: false, } }) .collect() @@ -454,6 +450,7 @@ fn gen_grouped_values(values: Vec<(PrintItems, usize)>, indent_width: u32, force }), allow_inline_multi_line: false, allow_inline_single_line: false, + is_known_multi_line: false, } }) .collect() @@ -479,9 +476,14 @@ fn gen_heredoc_instruction<'a>(node: &'a HeredocInstruction, context: &mut Conte let mut items = PrintItems::new(); // the first line is a normal instruction and is formatted as such items.extend(gen_node((&*node.instruction).into(), context)); - // the heredoc body and its closing delimiter(s) are preserved verbatim + // the heredoc body and its closing delimiter(s) are preserved verbatim: the + // delimiter only closes the heredoc when it matches exactly, so the printer + // must never indent these lines (`gen_from_raw_string` only ignores the + // indentation itself when the text spans multiple lines) items.push_signal(Signal::NewLine); + items.push_signal(Signal::StartIgnoringIndent); items.extend(gen_from_raw_string(&node.body)); + items.push_signal(Signal::FinishIgnoringIndent); items } @@ -536,6 +538,10 @@ fn gen_breakable_string<'a>(node: &'a BreakableString, context: &mut Context<'a> items.push_sc(continuation); items.push_signal(Signal::NewLine); } + // a line continued within a quote is string content, so the printer must not + // indent it: that would change the value and, because the added indentation + // is content the next parse sees, grow it on every format + let mut is_ignoring_indent = false; for (i, component) in node.components.iter().enumerate() { // comments lose their leading whitespace when parsed, so align them // with the surrounding arguments by reusing their indentation @@ -547,20 +553,31 @@ fn gen_breakable_string<'a>(node: &'a BreakableString, context: &mut Context<'a> } } items.extend(gen_node(component.into(), context)); + let ends_in_quote = use_quotes || context.collapse_shell_ws && context.shell_quote.is_some(); + if is_ignoring_indent && !ends_in_quote { + items.push_signal(Signal::FinishIgnoringIndent); + is_ignoring_indent = false; + } if i < node.components.len() - 1 { if let BreakableStringComponent::String(text) = component { // when the component ends inside a quote, any trailing whitespace is // part of the (kept) string content, so don't add a separator space - let ends_in_quote = context.collapse_shell_ws && context.shell_quote.is_some(); if !use_quotes && !ends_in_quote && text.content.ends_with(" ") { items.push_sc(space_continuation); } else { items.push_sc(continuation); } } + if ends_in_quote && !is_ignoring_indent { + items.push_signal(Signal::StartIgnoringIndent); + is_ignoring_indent = true; + } items.push_signal(Signal::NewLine); } } + if is_ignoring_indent { + items.push_signal(Signal::FinishIgnoringIndent); + } if use_quotes { items.push_sc(sc!("\"")); } @@ -588,6 +605,12 @@ fn string_leading_whitespace(component: &BreakableStringComponent) -> Option<&st } } +/// Removes up to `width` leading spaces or tabs. +fn dedent(text: &str, width: usize) -> &str { + let count = text.bytes().take(width).take_while(|b| matches!(b, b' ' | b'\t')).count(); + &text[count..] +} + fn gen_string<'a>(node: &'a SpannedString, context: &mut Context<'a>) -> PrintItems { let mut items = PrintItems::new(); if context.gen_string_content { diff --git a/src/generation/helpers.rs b/src/generation/helpers.rs index c53936c..030fcae 100644 --- a/src/generation/helpers.rs +++ b/src/generation/helpers.rs @@ -79,6 +79,10 @@ impl<'a> Node<'a> { pub fn is_comment(&self) -> bool { matches!(self, Node::Comment(_) | Node::CommentRc(_)) } + + pub fn is_from(&self) -> bool { + matches!(self, Node::From(_)) + } } pub fn parse_comments(text: &str, offset: usize) -> Vec { diff --git a/tests/specs/IndentStages/IndentStages_All.txt b/tests/specs/IndentStages/IndentStages_All.txt index f771aff..f40422a 100644 --- a/tests/specs/IndentStages/IndentStages_All.txt +++ b/tests/specs/IndentStages/IndentStages_All.txt @@ -157,3 +157,239 @@ ONBUILD RUN echo hi [expect] FROM alpine ONBUILD RUN echo hi + +== should keep a continuation line within a quote unchanged == +FROM alpine +RUN echo "hello \ + world" + +[expect] +FROM alpine + RUN echo "hello \ + world" + +== should keep a continuation line within a quote at column zero unchanged == +FROM alpine +RUN echo "a \ +b" + +[expect] +FROM alpine + RUN echo "a \ +b" + +== should keep a continuation line after an apostrophe unchanged == +FROM alpine +RUN echo don't \ + more + +[expect] +FROM alpine + RUN echo don't \ + more + +== should resume indenting after the quote closes == +FROM alpine +RUN echo "a \ + b" && echo c \ + d + +[expect] +FROM alpine + RUN echo "a \ + b" && echo c \ + d + +== should indent a continuation of a quote that opens and closes on one line == +FROM alpine + RUN echo "a b" \ + c + +[expect] +FROM alpine + RUN echo "a b" \ + c + +== should not indent the terminator of an empty heredoc == +FROM alpine +COPY <