Skip to content

Commit 69c7caf

Browse files
authored
šŸ› fix(common): preserve array entry trivia when reordering inline tables (#392)
1 parent 5745b6b commit 69c7caf

2 files changed

Lines changed: 282 additions & 24 deletions

File tree

ā€Žcommon/src/table.rsā€Ž

Lines changed: 125 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ use std::iter::zip;
44
use std::ops::Index;
55

66
use tombi_syntax::SyntaxKind::{
7-
ARRAY_OF_TABLE, BARE_KEY, BASIC_STRING, BRACKET_END, BRACKET_START, COMMENT, DANGLING_COMMENT_GROUP,
8-
DOUBLE_BRACKET_START, EQUAL, INLINE_TABLE, KEY_VALUE, KEY_VALUE_GROUP, KEY_VALUE_WITH_COMMA_GROUP, KEYS,
9-
LINE_BREAK, LITERAL_STRING, TABLE, WHITESPACE,
7+
ARRAY_OF_TABLE, BARE_KEY, BASIC_STRING, BRACE_START, BRACKET_END, BRACKET_START, COMMA, COMMENT,
8+
DANGLING_COMMENT_GROUP, DOUBLE_BRACKET_START, EQUAL, INLINE_TABLE, KEY_VALUE, KEY_VALUE_GROUP,
9+
KEY_VALUE_WITH_COMMA_GROUP, KEYS, LINE_BREAK, LITERAL_STRING, TABLE, WHITESPACE,
1010
};
1111
use tombi_syntax::{SyntaxElement, SyntaxKind, SyntaxNode};
1212

@@ -1200,46 +1200,148 @@ fn detect_schema<'a>(keys: &[String], schemas: &'a [InlineTableSchema]) -> Optio
12001200
.map(|s| s.key_order)
12011201
}
12021202

1203-
fn reorder_single_inline_table(node: &SyntaxNode, schemas: &[InlineTableSchema]) {
1204-
let kv_pairs: Vec<(String, String)> = node
1205-
.children()
1206-
.filter(|n| n.kind() == KEY_VALUE_WITH_COMMA_GROUP)
1207-
.flat_map(|group| {
1208-
group
1209-
.children()
1210-
.filter(|n| n.kind() == KEY_VALUE)
1211-
.map(|kv| (inline_table_key_name(&kv), kv.text().to_string().trim().to_string()))
1212-
.collect::<Vec<_>>()
1213-
})
1214-
.collect();
1203+
/// One key-value of an inline table together with the comments bound to it: `leading` holds
1204+
/// own-line comments that sit before the key, `trailing` the same-line comment after its comma.
1205+
/// Carrying both lets a key keep its comments when the keys get reordered.
1206+
struct InlineEntry {
1207+
key: String,
1208+
text: String,
1209+
leading: Vec<String>,
1210+
trailing: Option<String>,
1211+
}
1212+
1213+
/// Split a `KEY_VALUE` node into its `key = value` text and the own-line comments that precede
1214+
/// the key. Tombi stores those comments (and the multi-line layout) as leading trivia before the
1215+
/// `KEYS` child, so everything from `KEYS` onward is the value text and the rest is layout.
1216+
fn clean_key_value(kv: &SyntaxNode) -> (String, Vec<String>) {
1217+
let (mut text, mut leading, mut started) = (String::new(), Vec::new(), false);
1218+
for child in kv.children_with_tokens() {
1219+
if started {
1220+
text.push_str(&child.to_string());
1221+
} else if child.kind() == KEYS {
1222+
started = true;
1223+
text.push_str(&child.to_string());
1224+
} else if child.kind() == COMMENT {
1225+
leading.push(child.to_string().trim().to_string());
1226+
}
1227+
}
1228+
(text.trim().to_string(), leading)
1229+
}
1230+
1231+
/// Collect the inline table's entries with their comments, or `None` when the table holds a
1232+
/// comment that is not bound to a key. Own-line comments before a key live in that key's leading
1233+
/// trivia and trailing comments live in the preceding `COMMA`, so both move with their key. A
1234+
/// comment that is a direct child of the inline table (a dangling comment before the closing
1235+
/// brace) belongs to no key and would be lost by the rebuild, so the caller leaves the table
1236+
/// untouched instead.
1237+
fn collect_inline_entries(node: &SyntaxNode) -> Option<Vec<InlineEntry>> {
1238+
// Comments before `BRACE_START` are the array entry's own leading trivia (the splice keeps
1239+
// them); a comment inside the braces that is not part of a key or comma (a dangling comment
1240+
// before the closing brace) would be lost by the rebuild, so leave the table untouched.
1241+
if node
1242+
.children_with_tokens()
1243+
.skip_while(|c| c.kind() != BRACE_START)
1244+
.any(|c| matches!(c.kind(), COMMENT | DANGLING_COMMENT_GROUP))
1245+
{
1246+
return None;
1247+
}
1248+
let mut entries: Vec<InlineEntry> = Vec::new();
1249+
for group in node.children().filter(|n| n.kind() == KEY_VALUE_WITH_COMMA_GROUP) {
1250+
for child in group.children_with_tokens() {
1251+
match child.kind() {
1252+
KEY_VALUE => {
1253+
let kv = child.as_node().unwrap();
1254+
let (text, leading) = clean_key_value(kv);
1255+
entries.push(InlineEntry {
1256+
key: inline_table_key_name(kv),
1257+
text,
1258+
leading,
1259+
trailing: None,
1260+
});
1261+
}
1262+
COMMA => {
1263+
if let Some(comment) = child
1264+
.as_node()
1265+
.and_then(|comma| comma.children_with_tokens().find(|c| c.kind() == COMMENT))
1266+
{
1267+
entries
1268+
.last_mut()
1269+
.expect("a comma always follows a key-value in an inline table")
1270+
.trailing = Some(comment.to_string().trim().to_string());
1271+
}
1272+
}
1273+
_ => {}
1274+
}
1275+
}
1276+
}
1277+
Some(entries)
1278+
}
12151279

1216-
if kv_pairs.len() < 2 {
1280+
/// Render the reordered entries back into inline-table source. With no comments the compact
1281+
/// single-line form is kept; any comment forces the multi-line form, the only shape that can
1282+
/// carry a comment inside an inline table.
1283+
fn build_inline_table_text(entries: &[&InlineEntry]) -> String {
1284+
if entries.iter().all(|e| e.leading.is_empty() && e.trailing.is_none()) {
1285+
let joined = entries.iter().map(|e| e.text.as_str()).collect::<Vec<_>>().join(", ");
1286+
return format!("_x = {{ {joined} }}\n");
1287+
}
1288+
let mut out = String::from("_x = {\n");
1289+
for (idx, entry) in entries.iter().enumerate() {
1290+
for comment in &entry.leading {
1291+
out.push_str(" ");
1292+
out.push_str(comment);
1293+
out.push('\n');
1294+
}
1295+
out.push_str(" ");
1296+
out.push_str(&entry.text);
1297+
if idx + 1 < entries.len() {
1298+
out.push(',');
1299+
}
1300+
if let Some(comment) = &entry.trailing {
1301+
out.push(' ');
1302+
out.push_str(comment);
1303+
}
1304+
out.push('\n');
1305+
}
1306+
out.push_str("}\n");
1307+
out
1308+
}
1309+
1310+
fn reorder_single_inline_table(node: &SyntaxNode, schemas: &[InlineTableSchema]) {
1311+
let Some(entries) = collect_inline_entries(node) else {
1312+
return;
1313+
};
1314+
if entries.len() < 2 {
12171315
return;
12181316
}
12191317

1220-
let keys: Vec<String> = kv_pairs.iter().map(|(k, _)| k.clone()).collect();
1318+
let keys: Vec<String> = entries.iter().map(|e| e.key.clone()).collect();
12211319
let Some(schema) = detect_schema(&keys, schemas) else {
12221320
return;
12231321
};
12241322

12251323
let key_position = |k: &str| -> usize { schema.iter().position(|s| *s == k).unwrap_or(usize::MAX) };
1226-
let mut sorted = kv_pairs.clone();
1227-
sorted.sort_by_key(|(k, _)| key_position(k));
1324+
let mut order: Vec<usize> = (0..entries.len()).collect();
1325+
order.sort_by_key(|&i| key_position(&entries[i].key));
12281326

1229-
if sorted.iter().map(|(k, _)| k).eq(kv_pairs.iter().map(|(k, _)| k)) {
1327+
if order.iter().enumerate().all(|(new, &old)| new == old) {
12301328
return;
12311329
}
12321330

1233-
let entries: Vec<&str> = sorted.iter().map(|(_, v)| v.as_str()).collect();
1234-
let rebuilt = format!("_x = {{ {} }}\n", entries.join(", "));
1331+
let sorted: Vec<&InlineEntry> = order.iter().map(|&i| &entries[i]).collect();
1332+
let rebuilt = build_inline_table_text(&sorted);
12351333
let parsed = parse(&rebuilt);
12361334
let new_children: Option<Vec<SyntaxElement>> = parsed
12371335
.descendants()
12381336
.find(|n| n.kind() == INLINE_TABLE)
12391337
.map(|n| n.children_with_tokens().collect());
12401338

12411339
if let Some(children) = new_children {
1242-
node.splice_children(0..node.children_with_tokens().count(), children);
1340+
let original: Vec<SyntaxElement> = node.children_with_tokens().collect();
1341+
let brace_idx = original.iter().position(|c| c.kind() == BRACE_START).unwrap_or(0);
1342+
let mut merged = original[..brace_idx].to_vec();
1343+
merged.extend(children);
1344+
node.splice_children(0..original.len(), merged);
12431345
}
12441346
}
12451347

ā€Žcommon/src/tests/table_tests.rsā€Ž

Lines changed: 157 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2312,7 +2312,8 @@ fn test_reorder_inline_table_keys_in_array() {
23122312
insta::assert_snapshot!(result, @r#"
23132313
[section]
23142314
items = [
2315-
{ replace = "env", name = "A", default = "x" },{ prefix = "py3", start = 10, stop = 14 },
2315+
{ replace = "env", name = "A", default = "x" },
2316+
{ prefix = "py3", start = 10, stop = 14 },
23162317
]
23172318
"#);
23182319
}
@@ -2408,6 +2409,161 @@ fn test_reorder_inline_table_keys_unknown_keys_appended() {
24082409
"#);
24092410
}
24102411

2412+
const OVERRIDES_SCHEMAS: &[InlineTableSchema] = &[InlineTableSchema {
2413+
discriminator: "module",
2414+
key_order: &["module", "ignore_missing_imports"],
2415+
}];
2416+
2417+
fn override_count(rendered: &str) -> usize {
2418+
rendered
2419+
.parse::<toml::Table>()
2420+
.unwrap()
2421+
.get("tool")
2422+
.and_then(|t| t.get("mypy"))
2423+
.and_then(|m| m.get("overrides"))
2424+
.and_then(|o| o.as_array())
2425+
.map_or(0, Vec::len)
2426+
}
2427+
2428+
#[test]
2429+
fn test_reorder_inline_table_keys_array_trailing_comment_issue_387() {
2430+
let start = indoc! {r#"
2431+
[tool.mypy]
2432+
overrides = [
2433+
{ ignore_missing_imports = true, module = [ "a" ] }, # keep this comment
2434+
{ ignore_missing_imports = true, module = [ "b" ] },
2435+
]
2436+
"#};
2437+
let result = reorder_inline_helper(start, OVERRIDES_SCHEMAS);
2438+
crate::test_util::assert_valid_toml(&result);
2439+
assert_eq!(override_count(&result), 2);
2440+
insta::assert_snapshot!(result, @r#"
2441+
[tool.mypy]
2442+
overrides = [
2443+
{ module = [ "a" ], ignore_missing_imports = true }, # keep this comment
2444+
{ module = [ "b" ], ignore_missing_imports = true },
2445+
]
2446+
"#);
2447+
let again = reorder_inline_helper(&result, OVERRIDES_SCHEMAS);
2448+
assert_eq!(again, result);
2449+
}
2450+
2451+
#[test]
2452+
fn test_reorder_inline_table_keys_keeps_trailing_comment_inside_table() {
2453+
let start = indoc! {r#"
2454+
[section]
2455+
val = { default = "x", replace = "env", # keep me
2456+
name = "Y" }
2457+
"#};
2458+
let result = reorder_inline_helper(start, TEST_SCHEMAS);
2459+
crate::test_util::assert_valid_toml(&result);
2460+
assert!(result.contains("# keep me"));
2461+
insta::assert_snapshot!(result, @r#"
2462+
[section]
2463+
val = {
2464+
replace = "env", # keep me
2465+
name = "Y",
2466+
default = "x"
2467+
}
2468+
"#);
2469+
let again = reorder_inline_helper(&result, TEST_SCHEMAS);
2470+
assert_eq!(again, result);
2471+
}
2472+
2473+
#[test]
2474+
fn test_reorder_inline_table_keys_keeps_own_line_comment_inside_table() {
2475+
let start = indoc! {r#"
2476+
[section]
2477+
val = { default = "x",
2478+
# keep me
2479+
replace = "env", name = "Y" }
2480+
"#};
2481+
let result = reorder_inline_helper(start, TEST_SCHEMAS);
2482+
crate::test_util::assert_valid_toml(&result);
2483+
assert!(result.contains("# keep me"));
2484+
insta::assert_snapshot!(result, @r#"
2485+
[section]
2486+
val = {
2487+
# keep me
2488+
replace = "env",
2489+
name = "Y",
2490+
default = "x"
2491+
}
2492+
"#);
2493+
let again = reorder_inline_helper(&result, TEST_SCHEMAS);
2494+
assert_eq!(again, result);
2495+
}
2496+
2497+
#[test]
2498+
fn test_reorder_inline_table_keys_dangling_comment_left_untouched() {
2499+
let start = indoc! {r#"
2500+
[section]
2501+
val = { default = "x", replace = "env", name = "Y",
2502+
# dangling
2503+
}
2504+
"#};
2505+
let result = reorder_inline_helper(start, TEST_SCHEMAS);
2506+
crate::test_util::assert_valid_toml(&result);
2507+
assert_eq!(result, start);
2508+
}
2509+
2510+
const VALUE_SCHEMAS: &[InlineTableSchema] = &[InlineTableSchema {
2511+
discriminator: "kind",
2512+
key_order: &["kind", "items", "opts"],
2513+
}];
2514+
2515+
#[test]
2516+
fn test_reorder_inline_table_keys_preserves_list_and_table_values_with_comment() {
2517+
let start = indoc! {r#"
2518+
[section]
2519+
val = { opts = { deep = true }, items = [ "a", "b" ], # note
2520+
kind = "x" }
2521+
"#};
2522+
let result = reorder_inline_helper(start, VALUE_SCHEMAS);
2523+
crate::test_util::assert_valid_toml(&result);
2524+
let parsed = result.parse::<toml::Table>().unwrap();
2525+
let val = parsed["section"]["val"].as_table().unwrap();
2526+
assert_eq!(val["kind"].as_str(), Some("x"));
2527+
assert_eq!(val["items"].as_array().unwrap().len(), 2);
2528+
assert_eq!(val["opts"]["deep"].as_bool(), Some(true));
2529+
insta::assert_snapshot!(result, @r#"
2530+
[section]
2531+
val = {
2532+
kind = "x",
2533+
items = [ "a", "b" ], # note
2534+
opts = { deep = true }
2535+
}
2536+
"#);
2537+
let again = reorder_inline_helper(&result, VALUE_SCHEMAS);
2538+
assert_eq!(again, result);
2539+
}
2540+
2541+
#[test]
2542+
fn test_reorder_inline_table_keys_array_own_line_comment_issue_387() {
2543+
let start = indoc! {r#"
2544+
[tool.mypy]
2545+
overrides = [
2546+
{ ignore_missing_imports = true, module = [ "a" ] },
2547+
# keep this comment
2548+
{ ignore_missing_imports = true, module = [ "b" ] },
2549+
]
2550+
"#};
2551+
let result = reorder_inline_helper(start, OVERRIDES_SCHEMAS);
2552+
crate::test_util::assert_valid_toml(&result);
2553+
assert_eq!(override_count(&result), 2);
2554+
assert!(result.contains("# keep this comment"));
2555+
insta::assert_snapshot!(result, @r#"
2556+
[tool.mypy]
2557+
overrides = [
2558+
{ module = [ "a" ], ignore_missing_imports = true },
2559+
# keep this comment
2560+
{ module = [ "b" ], ignore_missing_imports = true },
2561+
]
2562+
"#);
2563+
let again = reorder_inline_helper(&result, OVERRIDES_SCHEMAS);
2564+
assert_eq!(again, result);
2565+
}
2566+
24112567
fn reorder_keys_render(start: &str, table_name: &str, order: &[&str]) -> String {
24122568
let root_ast = parse(start);
24132569
let tables = Tables::from_ast(&root_ast);

0 commit comments

Comments
Ā (0)