Ensure consistent indentation when adding dependencies (#14991)

## 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 <contact@zanie.dev>
This commit is contained in:
Charlie Marsh 2025-07-31 07:50:05 -04:00 committed by GitHub
parent fc0f637406
commit 3564e882d7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 74 additions and 19 deletions

View File

@ -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 => {

View File

@ -13595,3 +13595,58 @@ fn add_path_outside_workspace_no_default() -> Result<()> {
Ok(())
}
/// See: <https://github.com/astral-sh/uv/issues/14961>
#[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(())
}