From 3564e882d76931a099526f333e04e124aaa5eea7 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Thu, 31 Jul 2025 07:50:05 -0400 Subject: [PATCH] Ensure consistent indentation when adding dependencies (#14991) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The basic problem here is that when we had multiple items in an inline array, and that array expanded to multiple lines, we accidentally changed the indentation part-way through due to how prefixes work in the TOML. Here's Claude's explanation of the root cause, which I find pretty decent: ``` Here's what happened step by step: 1. First item ("iniconfig"): Has empty prefix "" → indentation_prefix stays None → uses default 4 spaces 2. Second item ("ruff"): Has empty prefix "" → indentation_prefix stays None → uses default 4 spaces 3. Third item ("typing-extensions"): Has prefix " " (single space from inline format) → indentation_prefix becomes Some(" ") → uses only 1 space! This produced: [dependency-groups] dev = [ "iniconfig>=2.0.0", "ruff", "typing-extensions", # ← Only 1 space instead of 4! ] Why the Third Item Had a Different Prefix In inline arrays like ["ruff", "typing-extensions"], the items are separated by commas and spaces. When parsed by the TOML library: - "ruff" has no prefix (it comes right after [) - "typing-extensions" has a single space prefix (the space after the comma) The Fix Moving the indentation calculation outside the loop ensures it's calculated only once: // Calculate indentation ONCE before the loop if let Some(first_item) = deps.iter().next() { let decor_prefix = /* get prefix from first item */ indentation_prefix = (!decor_prefix.is_empty()).then_some(decor_prefix.to_string()); } // Now use the same indentation for ALL items for item in deps.iter_mut() { // Apply consistent indentation to every item } This ensures all items get the same indentation (4 spaces by default when converting from inline arrays), producing the correct output: [dependency-groups] dev = [ "iniconfig>=2.0.0", "ruff", "typing-extensions", # ← Correct 4-space indentation ] The bug only affected arrays being converted from inline to multiline format, where different items might have different residual formatting from their inline representation. ``` Closes #14961. --------- Co-authored-by: Zanie Blue --- crates/uv-workspace/src/pyproject_mut.rs | 38 ++++++++-------- crates/uv/tests/it/edit.rs | 55 ++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 19 deletions(-) diff --git a/crates/uv-workspace/src/pyproject_mut.rs b/crates/uv-workspace/src/pyproject_mut.rs index 85c36d03d..efdc308c6 100644 --- a/crates/uv-workspace/src/pyproject_mut.rs +++ b/crates/uv-workspace/src/pyproject_mut.rs @@ -1562,29 +1562,29 @@ fn reformat_array_multiline(deps: &mut Array) { let mut indentation_prefix = None; + // Calculate the indentation prefix based on the indentation of the first dependency entry. + if let Some(first_item) = deps.iter().next() { + let decor_prefix = first_item + .decor() + .prefix() + .and_then(|s| s.as_str()) + .and_then(|s| s.lines().last()) + .unwrap_or_default(); + + let decor_prefix = decor_prefix + .split_once('#') + .map(|(s, _)| s) + .unwrap_or(decor_prefix); + + indentation_prefix = (!decor_prefix.is_empty()).then_some(decor_prefix.to_string()); + } + + let indentation_prefix_str = format!("\n{}", indentation_prefix.as_deref().unwrap_or(" ")); + for item in deps.iter_mut() { let decor = item.decor_mut(); let mut prefix = String::new(); - // Calculate the indentation prefix based on the indentation of the first dependency entry. - if indentation_prefix.is_none() { - let decor_prefix = decor - .prefix() - .and_then(|s| s.as_str()) - .and_then(|s| s.lines().last()) - .unwrap_or_default(); - - let decor_prefix = decor_prefix - .split_once('#') - .map(|(s, _)| s) - .unwrap_or(decor_prefix); - - indentation_prefix = (!decor_prefix.is_empty()).then_some(decor_prefix.to_string()); - } - - let indentation_prefix_str = - format!("\n{}", indentation_prefix.as_deref().unwrap_or(" ")); - for comment in find_comments(decor.prefix()).chain(find_comments(decor.suffix())) { match comment.comment_type { CommentType::OwnLine => { diff --git a/crates/uv/tests/it/edit.rs b/crates/uv/tests/it/edit.rs index 92d0d7b6a..1890f2451 100644 --- a/crates/uv/tests/it/edit.rs +++ b/crates/uv/tests/it/edit.rs @@ -13595,3 +13595,58 @@ fn add_path_outside_workspace_no_default() -> Result<()> { Ok(()) } + +/// See: +#[test] +fn add_multiline_indentation() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + + [dependency-groups] + dev = ["ruff", "typing-extensions"] + "#})?; + + uv_snapshot!(context.filters(), context.add().arg("iniconfig").arg("--dev"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 4 packages in [TIME] + Prepared 3 packages in [TIME] + Installed 3 packages in [TIME] + + iniconfig==2.0.0 + + ruff==0.3.4 + + typing-extensions==4.10.0 + "); + + let pyproject_toml = context.read("pyproject.toml"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyproject_toml, @r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + + [dependency-groups] + dev = [ + "iniconfig>=2.0.0", + "ruff", + "typing-extensions", + ] + "# + ); + }); + + Ok(()) +}