Skip to content

Commit d282396

Browse files
committed
feat(linter): fix for unsorted keys
1 parent 4c3f1ac commit d282396

File tree

2 files changed

+251
-2
lines changed

2 files changed

+251
-2
lines changed

crates/oxc_linter/src/rules/eslint/sort_keys.rs

Lines changed: 160 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ declare_oxc_lint!(
8484
SortKeys,
8585
eslint,
8686
style,
87-
pending
87+
conditional_fix
8888
);
8989

9090
impl Rule for SortKeys {
@@ -191,6 +191,147 @@ impl Rule for SortKeys {
191191
all(property_groups.iter().zip(&sorted_property_groups), |(a, b)| a == b);
192192

193193
if !is_sorted {
194+
// Try to provide a safe autofix when possible.
195+
// Conditions for providing a fix:
196+
// - No spread properties (reordering spreads is unsafe)
197+
// - All properties have a static key name
198+
// - No comments between adjacent properties
199+
// - No special grouping markers (we only support a single contiguous group)
200+
201+
let all_props = &dec.properties;
202+
let mut can_fix = true;
203+
let mut props: Vec<(String, Span)> = Vec::with_capacity(all_props.len());
204+
205+
for (i, prop) in all_props.iter().enumerate() {
206+
match prop {
207+
ObjectPropertyKind::SpreadProperty(_) => {
208+
can_fix = false;
209+
break;
210+
}
211+
ObjectPropertyKind::ObjectProperty(obj) => {
212+
let Some(key) = obj.key.static_name() else {
213+
can_fix = false;
214+
break;
215+
};
216+
props.push((key.to_string(), prop.span()));
217+
// check comments between this and next
218+
if i + 1 < all_props.len() {
219+
let next_span = all_props[i + 1].span();
220+
let between = Span::new(prop.span().end, next_span.start);
221+
if ctx.has_comments_between(between) {
222+
can_fix = false;
223+
break;
224+
}
225+
}
226+
}
227+
}
228+
}
229+
230+
if can_fix
231+
&& !props.is_empty()
232+
&& property_groups.len() == 1
233+
&& !property_groups[0].iter().any(|s| s.starts_with('<'))
234+
{
235+
// Build full text slices for each property spanning from the start of the
236+
// property to the start of the next property (or the end of the last one).
237+
// This preserves commas and formatting attached to each property so that
238+
// reordering produces syntactically-correct output.
239+
let mut prop_texts: Vec<String> = Vec::with_capacity(props.len());
240+
241+
for i in 0..props.len() {
242+
let start = props[i].1.start;
243+
let end =
244+
if i + 1 < props.len() { props[i + 1].1.start } else { props[i].1.end };
245+
prop_texts.push(ctx.source_range(Span::new(start, end)).to_string());
246+
}
247+
248+
// Prepare keys for comparison according to options
249+
let keys_for_cmp: Vec<String> = props
250+
.iter()
251+
.map(|(k, _)| {
252+
if self.case_sensitive {
253+
k.to_string()
254+
} else {
255+
k.cow_to_ascii_lowercase().to_string()
256+
}
257+
})
258+
.collect();
259+
260+
// Compute the sorted key order using the same helpers as the main rule
261+
// so the autofix ordering matches the diagnostic ordering.
262+
let mut sorted_keys = keys_for_cmp.clone();
263+
if self.natural {
264+
natural_sort(&mut sorted_keys);
265+
} else {
266+
alphanumeric_sort(&mut sorted_keys);
267+
}
268+
if self.sort_order == SortOrder::Desc {
269+
sorted_keys.reverse();
270+
}
271+
272+
// Map sorted keys back to indices in the original list. For duplicate
273+
// keys we consume the first unused occurrence.
274+
let mut used = vec![false; keys_for_cmp.len()];
275+
let mut indices: Vec<usize> = Vec::with_capacity(keys_for_cmp.len());
276+
277+
for sk in &sorted_keys {
278+
if let Some(pos) = keys_for_cmp
279+
.iter()
280+
.enumerate()
281+
.find(|(idx, k)| !used[*idx] && k.as_str() == sk.as_str())
282+
.map(|(i, _)| i)
283+
{
284+
used[pos] = true;
285+
indices.push(pos);
286+
}
287+
}
288+
289+
// Build sorted text by concatenating the full property snippets.
290+
// When moving snippets that used to be non-last (and thus include a
291+
// trailing comma) to the end, we must remove their trailing comma so
292+
// the resulting object doesn't end up with an extra comma before `}`.
293+
// Also normalize separators between properties to `, ` for clarity.
294+
let mut sorted_text = String::new();
295+
let trim_end_commas = |s: &str| -> String {
296+
let s = s.trim_end();
297+
let mut trimmed = s.to_string();
298+
299+
// remove a single trailing comma if present
300+
if trimmed.ends_with(',') {
301+
trimmed.pop();
302+
trimmed = trimmed.trim_end().to_string();
303+
}
304+
trimmed
305+
};
306+
307+
for (pos, &idx) in indices.iter().enumerate() {
308+
let is_last_in_new = pos + 1 == indices.len();
309+
let part = &prop_texts[idx];
310+
311+
if is_last_in_new {
312+
// Ensure last property does not end with a comma or extra space
313+
sorted_text.push_str(&trim_end_commas(part));
314+
} else {
315+
// For non-last properties, ensure there is exactly ", " after the
316+
// property (regardless of how it appeared originally).
317+
let trimmed = trim_end_commas(part);
318+
sorted_text.push_str(&trimmed);
319+
sorted_text.push_str(", ");
320+
}
321+
}
322+
323+
// Replace the full properties range
324+
let replace_span = Span::new(props[0].1.start, props[props.len() - 1].1.end);
325+
326+
ctx.diagnostic_with_fix(sort_properties_diagnostic(node.span()), |fixer| {
327+
fixer.replace(replace_span, sorted_text)
328+
});
329+
330+
// we've emitted a fix for this node; stop processing this node
331+
return;
332+
}
333+
334+
// Fallback: still emit diagnostic if we couldn't produce a safe fix
194335
ctx.diagnostic(sort_properties_diagnostic(node.span()));
195336
}
196337
}
@@ -1024,5 +1165,22 @@ fn test() {
10241165
), // { "ecmaVersion": 2018 }
10251166
];
10261167

1027-
Tester::new(SortKeys::NAME, SortKeys::PLUGIN, pass, fail).test_and_snapshot();
1168+
// Add comprehensive fixer tests: the rule now advertises conditional fixes,
1169+
// so provide expect_fix cases.
1170+
let fix = vec![
1171+
// Basic alphabetical sorting
1172+
("var obj = {b:1, a:2}", "var obj = {a:2, b:1}"),
1173+
// Case sensitivity - lowercase comes after uppercase, so a:2 should come after B:1
1174+
("var obj = {a:1, B:2}", "var obj = {B:2, a:1}"),
1175+
// Trailing commas preserved
1176+
("var obj = {b:1, a:2,}", "var obj = {a:2, b:1,}"),
1177+
// With spaces and various formatting
1178+
("var obj = { z: 1, a: 2 }", "var obj = { a: 2, z: 1 }"),
1179+
// Three properties
1180+
("var obj = {c:1, a:2, b:3}", "var obj = {a:2, b:3, c:1}"),
1181+
// Mixed types
1182+
("var obj = {2:1, a:2, 1:3}", "var obj = {1:3, 2:1, a:2}"),
1183+
];
1184+
1185+
Tester::new(SortKeys::NAME, SortKeys::PLUGIN, pass, fail).expect_fix(fix).test_and_snapshot();
10281186
}

0 commit comments

Comments
 (0)