Skip to content

Commit 10ae8a7

Browse files
committed
🐛 fix: let the guard compare the marker its own pass chose
A comment the file wrote that spelled the marker was read as one, so an active entry beside it was left out of nesting, collapse and migration. The pass now holds the marker it picked while the formatter runs, and the guard answers with that, so nothing the file says can stand in for it. Mypy's collapsed overrides are found by their path rather than by the name they end in, which was sorting an `overrides` array under any tool.
1 parent 7095f0e commit 10ae8a7

9 files changed

Lines changed: 117 additions & 103 deletions

File tree

common/src/disabled.rs

Lines changed: 46 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -15,38 +15,53 @@ use std::collections::HashSet;
1515
/// from this one that the file does not already contain.
1616
pub const MARKER: &str = "__toml_fmt_disabled__";
1717

18-
/// Whether the entry is one this pass turned back on, which is what a comment carrying [`MARKER`]
19-
/// says.
18+
thread_local! {
19+
/// The marker the pass running on this thread chose. A guard outside a pass has none, so
20+
/// nothing the file wrote is read as one.
21+
static IN_USE: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
22+
}
23+
24+
/// Holds the marker for as long as the pass runs, and takes it back however the pass ends.
25+
struct InUse;
26+
27+
impl InUse {
28+
fn hold(marker: &str) -> Self {
29+
IN_USE.with_borrow_mut(|held| *held = Some(marker.to_owned()));
30+
Self
31+
}
32+
}
33+
34+
impl Drop for InUse {
35+
fn drop(&mut self) {
36+
IN_USE.with_borrow_mut(|held| *held = None);
37+
}
38+
}
39+
40+
/// Whether the entry is one this pass turned back on, which is what the marker beside it says.
2041
///
2142
/// A pass that would split, drop or merge such an entry has to leave it alone: what says the entry
2243
/// is disabled is the comment beside it, and none of those rewrites can say it of the entries they
2344
/// leave behind.
2445
#[must_use]
2546
pub fn is_enabled_here(entry: &toml_doc::Entry<'_>) -> bool {
26-
entry
27-
.trail
28-
.comment
29-
.as_ref()
30-
.is_some_and(|comment| marks_a_disabled_key(comment))
47+
let Some(comment) = entry.trail.comment.as_ref() else {
48+
return false;
49+
};
50+
IN_USE.with_borrow(|held| {
51+
held.as_deref()
52+
.is_some_and(|marker| marks_a_disabled_key(comment, marker))
53+
})
3154
}
3255

33-
/// Whether the comment is one [`enabled_form`] wrote: a comment holding nothing but the marker, or
34-
/// the marker written at the end of a comment the file already had. Prose that merely names the
35-
/// marker is a comment like any other.
36-
fn marks_a_disabled_key(comment: &str) -> bool {
56+
/// Whether the comment is the one [`enabled_form`] wrote for this pass: a comment holding nothing
57+
/// but the marker, or the marker written at the end of a comment the file already had.
58+
fn marks_a_disabled_key(comment: &str, marker: &str) -> bool {
3759
let body = comment.trim_start_matches('#').trim();
38-
is_a_marker(body)
60+
body == marker
3961
|| body
4062
.rsplit(char::is_whitespace)
4163
.next()
42-
.and_then(|last| last.strip_suffix("-kept"))
43-
.is_some_and(is_a_marker)
44-
}
45-
46-
/// Whether the text is [`MARKER`] or one of the longer ones [`fresh_marker`] builds from it.
47-
fn is_a_marker(text: &str) -> bool {
48-
text.strip_prefix(MARKER)
49-
.is_some_and(|rest| rest.chars().all(|held| held == 'x'))
64+
.is_some_and(|last| last == kept_marker(marker))
5065
}
5166

5267
/// A marker the source does not already hold, so nothing the file says can be read as one.
@@ -66,7 +81,12 @@ fn fresh_marker(source: &str) -> String {
6681
/// wrote a comment on a line of its own, so turning one back on leaves a document that still reads.
6782
pub fn with_disabled_keys(content: &str, format: impl FnOnce(&str) -> String) -> String {
6883
let marker = fresh_marker(content);
69-
restore_disabled_keys(&format(&enable_disabled_keys(content, &marker)), &marker)
84+
let enabled = enable_disabled_keys(content, &marker);
85+
let formatted = {
86+
let _in_use = InUse::hold(&marker);
87+
format(&enabled)
88+
};
89+
restore_disabled_keys(&formatted, &marker)
7090
}
7191

7292
/// [`with_disabled_keys`] for a formatter that may reject its input; a rejected pass restores nothing.
@@ -77,7 +97,11 @@ pub fn with_disabled_keys(content: &str, format: impl FnOnce(&str) -> String) ->
7797
pub fn try_with_disabled_keys<E>(content: &str, format: impl FnOnce(&str) -> Result<String, E>) -> Result<String, E> {
7898
let marker = fresh_marker(content);
7999
let enabled = enable_disabled_keys(content, &marker);
80-
Ok(restore_disabled_keys(&format(&enabled)?, &marker))
100+
let formatted = {
101+
let _in_use = InUse::hold(&marker);
102+
format(&enabled)
103+
};
104+
Ok(restore_disabled_keys(&formatted?, &marker))
81105
}
82106

83107
/// The lines the file wrote a comment on where a key could have stood: what leads an entry or a

common/src/sections.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,7 @@ pub fn for_array_elements(
310310

311311
/// Every value written at the dotted path, wherever the file put the split between the header it
312312
/// wrote and the keys under it.
313-
fn values_at<'d, 'a>(document: &'d mut Document<'a>, path: &[String]) -> Vec<&'d mut Value<'a>> {
313+
pub fn values_at<'d, 'a>(document: &'d mut Document<'a>, path: &[String]) -> Vec<&'d mut Value<'a>> {
314314
let mut found: Vec<&'d mut Value<'a>> = Vec::new();
315315
for entry in active(&mut document.root) {
316316
if entry.key_value.key.segments() == path {

common/tests/disabled.rs

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -184,20 +184,31 @@ fn prose_above_a_disabled_key_keeps_its_hashes() {
184184
);
185185
}
186186

187-
/// Prose that names the marker is a comment like any other, so what it is written beside is
188-
/// ordinary configuration a rule reads.
189-
#[test]
190-
fn a_comment_naming_the_marker_is_not_a_disabled_key() {
191-
let source = format!("[tool.x]\nsub.a = 1 # see {} for why\n", common::disabled::MARKER);
192-
let mut document = toml_doc::parse(&source).expect("valid source");
193-
common::nesting::expand(&mut document, "tool.x");
194-
187+
/// The marker names the pass that wrote it, so a comment the file wrote is ordinary configuration
188+
/// however closely it spells one.
189+
#[test]
190+
fn a_comment_the_file_wrote_is_not_a_disabled_key() {
191+
let wrote = |comment: String| {
192+
let source = format!("[tool.x]\nsub.a = 1 # {comment}\nheld = \"{MARKER}\"\n");
193+
with_disabled_keys(&source, |content| {
194+
let mut document = toml_doc::parse(content).expect("valid source");
195+
common::nesting::expand(&mut document, "tool.x");
196+
document.to_string()
197+
})
198+
};
199+
200+
// the file already holds the marker text, so the pass runs with a longer one
201+
assert_eq!(
202+
wrote(String::from(MARKER)),
203+
format!("[tool.x]\nheld = \"{MARKER}\"\n[tool.x.sub]\na = 1 # {MARKER}\n")
204+
);
205+
assert_eq!(
206+
wrote(format!("{MARKER}-kept")),
207+
format!("[tool.x]\nheld = \"{MARKER}\"\n[tool.x.sub]\na = 1 # {MARKER}-kept\n")
208+
);
195209
assert_eq!(
196-
document.to_string(),
197-
format!(
198-
"[tool.x]\n[tool.x.sub]\na = 1 # see {} for why\n",
199-
common::disabled::MARKER
200-
)
210+
wrote(format!("see {MARKER} for why")),
211+
format!("[tool.x]\nheld = \"{MARKER}\"\n[tool.x.sub]\na = 1 # see {MARKER} for why\n")
201212
);
202213
}
203214

common/tests/nesting.rs

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -271,17 +271,13 @@ fn an_element_holding_a_comment_keeps_the_array_written_out() {
271271
/// carry none of that.
272272
#[test]
273273
fn a_disabled_key_is_not_written_out_into_a_header() {
274-
let source = format!("[tool.x]\nsub.a = 1 # {}\nsub.b = 2\n", common::disabled::MARKER);
275-
let mut document = parse(&source);
276-
nesting::expand(&mut document, "tool.x");
274+
let formatted = common::disabled::with_disabled_keys("[tool.x]\n# sub.a = 1\nsub.b = 2\n", |content| {
275+
let mut document = parse(content);
276+
nesting::expand(&mut document, "tool.x");
277+
document.to_string()
278+
});
277279

278-
assert_eq!(
279-
document.to_string(),
280-
format!(
281-
"[tool.x]\nsub.a = 1 # {}\n[tool.x.sub]\nb = 2\n",
282-
common::disabled::MARKER
283-
)
284-
);
280+
assert_eq!(formatted, "[tool.x]\n# sub.a = 1\n[tool.x.sub]\nb = 2\n");
285281
}
286282

287283
/// A dotted key at the root already writes the parent out, and a header synthesized beside it would
@@ -335,9 +331,12 @@ fn children_of_several_array_elements_stay_written_out() {
335331
/// parent would leave nothing saying the table is there.
336332
#[test]
337333
fn a_table_of_only_disabled_keys_is_not_folded() {
338-
let source = format!("[tool.x]\na = 1\n[tool.x.sub]\nb = 2 # {}\n", common::disabled::MARKER);
339-
let mut document = parse(&source);
340-
nesting::collapse(&mut document, "tool.x");
334+
let source = "[tool.x]\na = 1\n[tool.x.sub]\n# b = 2\n";
335+
let formatted = common::disabled::with_disabled_keys(source, |content| {
336+
let mut document = parse(content);
337+
nesting::collapse(&mut document, "tool.x");
338+
document.to_string()
339+
});
341340

342-
assert_eq!(document.to_string(), source);
341+
assert_eq!(formatted, source);
343342
}

common/tests/sections.rs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -588,11 +588,13 @@ fn the_tables_of_an_ordered_name_hold_their_place() {
588588
/// A key the file wrote as a comment says nothing to a rule reading what the file says.
589589
#[test]
590590
fn a_disabled_key_is_not_read_as_an_entry() {
591-
let source = format!("[tool.x]\na = 1 # {}\nb = 2\n", common::disabled::MARKER);
592-
let mut document = parse(&source);
593-
let section = sections::first(&mut document, "tool.x").expect("section");
594591
let mut seen = Vec::new();
595-
sections::for_entries(section, |key, _value| seen.push(key.to_owned()));
592+
common::disabled::with_disabled_keys("[tool.x]\n# a = 1\nb = 2\n", |content| {
593+
let mut document = parse(content);
594+
let section = sections::first(&mut document, "tool.x").expect("section");
595+
sections::for_entries(section, |key, _value| seen.push(key.to_owned()));
596+
content.to_owned()
597+
});
596598

597599
assert_eq!(seen, ["b"]);
598600
}

pyproject-fmt/rust/src/mypy.rs

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -241,17 +241,9 @@ pub fn reorder_inline_tables(document: &mut Document<'_>) {
241241
/// A collapsed `[[tool.mypy.overrides]]` becomes `overrides = [ {...}, {...} ]`, which puts its
242242
/// arrays inside a value rather than under a table, out of reach of the entry walk above.
243243
fn sort_arrays_inside_overrides(document: &mut Document<'_>) {
244-
let root = common::sections::active(&mut document.root);
245-
let held = document
246-
.sections
247-
.iter_mut()
248-
.flat_map(|section| common::sections::active(&mut section.entries));
249-
for entry in root.chain(held) {
250-
let key = common::sections::dispatch_name(&entry.key_value.key);
251-
if key != "overrides" && !key.ends_with(".overrides") {
252-
continue;
253-
}
254-
let Value::Array(array) = &mut entry.key_value.value else {
244+
let path = ["tool", "mypy", "overrides"].map(str::to_owned);
245+
for value in common::sections::values_at(document, &path) {
246+
let Value::Array(array) = value else {
255247
continue;
256248
};
257249
for member in &mut array.members {

pyproject-fmt/rust/src/tests/main_tests.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1813,3 +1813,26 @@ fn test_a_commented_field_keeps_the_order_it_was_written_in() {
18131813
"#);
18141814
assert_eq!(format_toml(&result, &default_settings()).unwrap(), result);
18151815
}
1816+
1817+
/// An `overrides` key belongs to the tool whose table it sits in, so mypy's rule for its own
1818+
/// overrides leaves another tool's alone.
1819+
#[test]
1820+
fn test_overrides_under_another_tool_keep_their_order() {
1821+
let start = indoc! {r#"
1822+
[tool.other]
1823+
overrides = [{ module = ["z", "a"] }]
1824+
1825+
[tool.mypy]
1826+
overrides = [{ module = ["z", "a"] }]
1827+
"#};
1828+
let result = format_toml(start, &default_settings()).unwrap();
1829+
assert_valid_toml(&result);
1830+
insta::assert_snapshot!(result, @r#"
1831+
[tool.mypy]
1832+
overrides = [ { module = [ "a", "z" ] } ]
1833+
1834+
[tool.other]
1835+
overrides = [ { module = [ "z", "a" ] } ]
1836+
"#);
1837+
assert_eq!(format_toml(&result, &default_settings()).unwrap(), result);
1838+
}

pyproject-fmt/rust/src/tests/project_tests.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2226,6 +2226,7 @@ fn test_project_requires_python_reads_the_whole_specifier_set() {
22262226
assert_eq!(minors("<=3.10.1,<=3.10.5"), named(&["9", "10"]));
22272227
}
22282228

2229+
22292230
/// Text that is not a specifier set says nothing about what the project supports, so the configured
22302231
/// window stands.
22312232
#[test]

review.md

Lines changed: 0 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -214,44 +214,6 @@ rules crate. This preserves one tox implementation without making either end-use
214214
dependency. Put workflow path filters and shared Rust inputs in one reusable data source, or validate every consumer
215215
bidirectionally from a repository-level test without mutating Python's import path.
216216

217-
## 76. P2: Mypy's collapsed-override array rules still run across the whole document
218-
219-
Locations: `pyproject-fmt/rust/src/mypy.rs:235-266` and `pyproject-fmt/rust/src/main.rs:235-239`
220-
221-
The path added to `sections::reorder_inline_tables` now keeps every schema under its owning tool, fixing the reproduced
222-
cross-tool inline-table rewrite. Mypy's additional `sort_arrays_inside_overrides` pass still scans every root and
223-
section entry, however. It selects any entry named `overrides` or ending in `.overrides`, without checking that its
224-
semantic path is `tool.mypy.overrides`.
225-
226-
An unrelated tool can therefore have arrays inside an inline-table array sorted according to mypy's schema. For example,
227-
`[tool.other] overrides = [{ module = ["z", "a"] }]` is rewritten to `module = ["a", "z"]`. This is an end-user semantic
228-
change if that tool treats the array as ordered.
229-
230-
Find the collapsed override value through a segment-aware `tool.mypy.overrides` path selector instead of scanning by
231-
leaf name. Add a public case for the same shape under an unrelated tool and keep its member order unchanged.
232-
233-
## 79. P2: Marker-prefix comments can still masquerade as disabled entries
234-
235-
Locations: `common/src/disabled.rs:20-53`, `common/src/sections.rs:102-116`, `common/src/nesting.rs:305-316`,
236-
`pyproject-fmt/rust/src/project.rs:203-214`, and `tox-toml-fmt/rust/src/global.rs:235-254`
237-
238-
Restricting the guard to a standalone marker removes the broad prose false positive, but `is_enabled_here` still does
239-
not know which marker `fresh_marker` selected for this formatting run. `is_a_marker` accepts the base marker followed by
240-
any number of `x` characters. If an active entry already ends in `# __toml_fmt_disabled__`, collision handling chooses
241-
`__toml_fmt_disabled__x`; the guard nevertheless recognizes the shorter user comment and treats the active entry as
242-
disabled. The same false positive remains for an exact `__toml_fmt_disabled__-kept` suffix.
243-
244-
The new prose test puts words after the marker, so it cannot exercise either accepted shape. The longer-marker test
245-
proves that a real disabled entry remains guarded but not that an active colliding comment remains active. Depending on
246-
the caller, the false positive skips nesting, table collapse, entry-point expansion, or Tox migration. A comment still
247-
changes which formatter rules run.
248-
249-
Pass the marker selected by `with_disabled_keys` through the formatter and into every active-entry guard, or carry
250-
explicit disabled state in the parsed model. The guard should compare the exact marker, including the exact `-kept`
251-
suffix, rather than recognize a marker family. Add public cases where active structural entries end in the exact base
252-
marker and base `-kept` marker while collision handling selects a longer marker. Keep the new prose and genuine
253-
longer-marker cases; they protect distinct behavior.
254-
255217
## 81. P2: Disabled-key preprocessing has cubic and quadratic scans
256218

257219
Locations: `common/src/disabled.rs:31-37` and `common/src/disabled.rs:62-110`

0 commit comments

Comments
 (0)