Skip to content

Commit 20053f3

Browse files
Fix to respect comments positioning in pyproject.toml on change (#8384)
## Summary This PR is is to address the problem when the same-line comments in `pyproject.toml` could be found in unpredictable positions after `uv add` or `remove` reformats the `pyproject.toml` file. Introduced the `Comment` structure in `pyproject_mut` module to distinguish "same-line" comments and "full-line" comments while reformatting, because logic for them differs. Sorry, the implementation could be clumsy, I'm just learning Rust, but it seems to work 😅 Closes #8343 ## Test Plan Added the new test: `add_preserves_comments_indentation_and_sameline_comments` To test followed the actions from the issue ticket #8343 --------- Co-authored-by: Charlie Marsh <[email protected]>
1 parent e9f1161 commit 20053f3

File tree

2 files changed

+194
-10
lines changed

2 files changed

+194
-10
lines changed

crates/uv-workspace/src/pyproject_mut.rs

+60-10
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,20 @@ pub enum ArrayEdit {
4949
Add(usize),
5050
}
5151

52+
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
53+
enum CommentType {
54+
/// A comment that appears on its own line.
55+
OwnLine,
56+
/// A comment that appears at the end of a line.
57+
EndOfLine,
58+
}
59+
60+
#[derive(Debug, Clone)]
61+
struct Comment {
62+
text: String,
63+
comment_type: CommentType,
64+
}
65+
5266
impl ArrayEdit {
5367
pub fn index(&self) -> usize {
5468
match self {
@@ -694,7 +708,12 @@ pub fn add_dependency(
694708
};
695709
let index = index.unwrap_or(deps.len());
696710

697-
deps.insert(index, req_string);
711+
let mut value = Value::from(req_string.as_str());
712+
let decor = value.decor_mut();
713+
decor.set_prefix(deps.trailing().clone());
714+
deps.set_trailing("");
715+
716+
deps.insert_formatted(index, value);
698717
// `reformat_array_multiline` uses the indentation of the first dependency entry.
699718
// Therefore, we retrieve the indentation of the first dependency entry and apply it to
700719
// the new entry. Note that it is only necessary if the newly added dependency is going
@@ -829,14 +848,38 @@ fn try_parse_requirement(req: &str) -> Option<Requirement> {
829848
/// Reformats a TOML array to multi line while trying to preserve all comments
830849
/// and move them around. This also formats the array to have a trailing comma.
831850
fn reformat_array_multiline(deps: &mut Array) {
832-
fn find_comments(s: Option<&RawString>) -> impl Iterator<Item = &str> {
833-
s.and_then(|x| x.as_str())
851+
fn find_comments(s: Option<&RawString>) -> Box<dyn Iterator<Item = Comment> + '_> {
852+
let iter = s
853+
.and_then(|x| x.as_str())
834854
.unwrap_or("")
835855
.lines()
836-
.filter_map(|line| {
837-
let line = line.trim();
838-
line.starts_with('#').then_some(line)
839-
})
856+
.scan(
857+
(false, false),
858+
|(prev_line_was_empty, prev_line_was_comment), line| {
859+
let trimmed_line = line.trim();
860+
if let Some(index) = trimmed_line.find('#') {
861+
let comment_text = trimmed_line[index..].trim().to_string();
862+
let comment_type = if (*prev_line_was_empty) || (*prev_line_was_comment) {
863+
CommentType::OwnLine
864+
} else {
865+
CommentType::EndOfLine
866+
};
867+
*prev_line_was_empty = trimmed_line.is_empty();
868+
*prev_line_was_comment = true;
869+
Some(Some(Comment {
870+
text: comment_text,
871+
comment_type,
872+
}))
873+
} else {
874+
*prev_line_was_empty = trimmed_line.is_empty();
875+
*prev_line_was_comment = false;
876+
Some(None)
877+
}
878+
},
879+
)
880+
.flatten();
881+
882+
Box::new(iter)
840883
}
841884

842885
let mut indentation_prefix = None;
@@ -866,8 +909,15 @@ fn reformat_array_multiline(deps: &mut Array) {
866909
let indentation_prefix_str = format!("\n{}", indentation_prefix.as_ref().unwrap());
867910

868911
for comment in find_comments(decor.prefix()).chain(find_comments(decor.suffix())) {
869-
prefix.push_str(&indentation_prefix_str);
870-
prefix.push_str(comment);
912+
match comment.comment_type {
913+
CommentType::OwnLine => {
914+
prefix.push_str(&indentation_prefix_str);
915+
}
916+
CommentType::EndOfLine => {
917+
prefix.push(' ');
918+
}
919+
}
920+
prefix.push_str(&comment.text);
871921
}
872922
prefix.push_str(&indentation_prefix_str);
873923
decor.set_prefix(prefix);
@@ -880,7 +930,7 @@ fn reformat_array_multiline(deps: &mut Array) {
880930
if comments.peek().is_some() {
881931
for comment in comments {
882932
rv.push_str("\n ");
883-
rv.push_str(comment);
933+
rv.push_str(&comment.text);
884934
}
885935
}
886936
if !rv.is_empty() || !deps.is_empty() {

crates/uv/tests/it/edit.rs

+134
Original file line numberDiff line numberDiff line change
@@ -6269,3 +6269,137 @@ fn add_self() -> Result<()> {
62696269

62706270
Ok(())
62716271
}
6272+
6273+
#[test]
6274+
fn add_preserves_end_of_line_comments() -> Result<()> {
6275+
let context = TestContext::new("3.12");
6276+
6277+
let pyproject_toml = context.temp_dir.child("pyproject.toml");
6278+
pyproject_toml.write_str(indoc! {r#"
6279+
[project]
6280+
name = "project"
6281+
version = "0.1.0"
6282+
requires-python = ">=3.12"
6283+
dependencies = [
6284+
# comment 0
6285+
"anyio==3.7.0", # comment 1
6286+
# comment 2
6287+
]
6288+
6289+
[build-system]
6290+
requires = ["setuptools>=42"]
6291+
build-backend = "setuptools.build_meta"
6292+
"#})?;
6293+
6294+
uv_snapshot!(context.filters(), context.add().arg("requests==2.31.0"), @r###"
6295+
success: true
6296+
exit_code: 0
6297+
----- stdout -----
6298+
6299+
----- stderr -----
6300+
Resolved 8 packages in [TIME]
6301+
Prepared 8 packages in [TIME]
6302+
Installed 8 packages in [TIME]
6303+
+ anyio==3.7.0
6304+
+ certifi==2024.2.2
6305+
+ charset-normalizer==3.3.2
6306+
+ idna==3.6
6307+
+ project==0.1.0 (from file://[TEMP_DIR]/)
6308+
+ requests==2.31.0
6309+
+ sniffio==1.3.1
6310+
+ urllib3==2.2.1
6311+
"###);
6312+
6313+
let pyproject_toml = context.read("pyproject.toml");
6314+
6315+
insta::with_settings!({
6316+
filters => context.filters(),
6317+
}, {
6318+
assert_snapshot!(
6319+
pyproject_toml, @r###"
6320+
[project]
6321+
name = "project"
6322+
version = "0.1.0"
6323+
requires-python = ">=3.12"
6324+
dependencies = [
6325+
# comment 0
6326+
"anyio==3.7.0", # comment 1
6327+
# comment 2
6328+
"requests==2.31.0",
6329+
]
6330+
6331+
[build-system]
6332+
requires = ["setuptools>=42"]
6333+
build-backend = "setuptools.build_meta"
6334+
"###
6335+
);
6336+
});
6337+
Ok(())
6338+
}
6339+
6340+
#[test]
6341+
fn add_preserves_open_bracket_comment() -> Result<()> {
6342+
let context = TestContext::new("3.12");
6343+
6344+
let pyproject_toml = context.temp_dir.child("pyproject.toml");
6345+
pyproject_toml.write_str(indoc! {r#"
6346+
[project]
6347+
name = "project"
6348+
version = "0.1.0"
6349+
requires-python = ">=3.12"
6350+
dependencies = [ # comment 0
6351+
# comment 1
6352+
"anyio==3.7.0", # comment 2
6353+
# comment 3
6354+
]
6355+
6356+
[build-system]
6357+
requires = ["setuptools>=42"]
6358+
build-backend = "setuptools.build_meta"
6359+
"#})?;
6360+
6361+
uv_snapshot!(context.filters(), context.add().arg("requests==2.31.0"), @r###"
6362+
success: true
6363+
exit_code: 0
6364+
----- stdout -----
6365+
6366+
----- stderr -----
6367+
Resolved 8 packages in [TIME]
6368+
Prepared 8 packages in [TIME]
6369+
Installed 8 packages in [TIME]
6370+
+ anyio==3.7.0
6371+
+ certifi==2024.2.2
6372+
+ charset-normalizer==3.3.2
6373+
+ idna==3.6
6374+
+ project==0.1.0 (from file://[TEMP_DIR]/)
6375+
+ requests==2.31.0
6376+
+ sniffio==1.3.1
6377+
+ urllib3==2.2.1
6378+
"###);
6379+
6380+
let pyproject_toml = context.read("pyproject.toml");
6381+
6382+
insta::with_settings!({
6383+
filters => context.filters(),
6384+
}, {
6385+
assert_snapshot!(
6386+
pyproject_toml, @r###"
6387+
[project]
6388+
name = "project"
6389+
version = "0.1.0"
6390+
requires-python = ">=3.12"
6391+
dependencies = [ # comment 0
6392+
# comment 1
6393+
"anyio==3.7.0", # comment 2
6394+
# comment 3
6395+
"requests==2.31.0",
6396+
]
6397+
6398+
[build-system]
6399+
requires = ["setuptools>=42"]
6400+
build-backend = "setuptools.build_meta"
6401+
"###
6402+
);
6403+
});
6404+
Ok(())
6405+
}

0 commit comments

Comments
 (0)