Skip to content

Commit 678e43b

Browse files
committed
fix(linter/unicorn): fix ASI hazard in prefer-spread rule fixer (#16440)
🤖 generated with help from Claude Opus 4.5
1 parent 5b10825 commit 678e43b

File tree

2 files changed

+278
-8
lines changed

2 files changed

+278
-8
lines changed

crates/oxc_linter/src/ast_util.rs

Lines changed: 160 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@ use oxc_ast::{
1010
use oxc_ecmascript::{ToBoolean, WithoutGlobalReferenceInformation};
1111
use oxc_semantic::{AstNode, AstNodes, IsGlobalReference, NodeId, ReferenceId, Semantic, SymbolId};
1212
use oxc_span::{GetSpan, Span};
13-
use oxc_syntax::operator::{AssignmentOperator, BinaryOperator, LogicalOperator, UnaryOperator};
13+
use oxc_syntax::{
14+
identifier::is_irregular_whitespace,
15+
operator::{AssignmentOperator, BinaryOperator, LogicalOperator, UnaryOperator},
16+
};
1417

1518
use crate::{LintContext, utils::get_function_nearest_jsdoc_node};
1619

@@ -975,3 +978,159 @@ pub fn is_node_within_call_argument<'a>(
975978
let arg_span = target_arg.span();
976979
node_span.start >= arg_span.start && node_span.end <= arg_span.end
977980
}
981+
982+
/// Determines if a semicolon is needed before inserting code that starts with
983+
/// certain characters (`[`, `(`, `/`, `+`, `-`, `` ` ``) that could be misinterpreted
984+
/// due to Automatic Semicolon Insertion (ASI) rules.
985+
///
986+
/// Returns `true` if the node is at the start of an `ExpressionStatement` and the
987+
/// character before it could cause the replacement to be parsed as a continuation
988+
/// of the previous expression.
989+
pub fn could_be_asi_hazard(node: &AstNode, ctx: &LintContext) -> bool {
990+
let node_span = node.span();
991+
992+
// Find the enclosing ExpressionStatement, bailing early for nodes that can't
993+
// be at statement start position
994+
let mut expr_stmt_span = None;
995+
for ancestor in ctx.nodes().ancestors(node.id()) {
996+
match ancestor.kind() {
997+
AstKind::ExpressionStatement(expr_stmt) => {
998+
expr_stmt_span = Some(expr_stmt.span);
999+
break;
1000+
}
1001+
// Expression types that can have our node at their start position
1002+
AstKind::CallExpression(_)
1003+
| AstKind::ComputedMemberExpression(_)
1004+
| AstKind::StaticMemberExpression(_)
1005+
| AstKind::PrivateFieldExpression(_)
1006+
| AstKind::ChainExpression(_)
1007+
| AstKind::TaggedTemplateExpression(_)
1008+
| AstKind::SequenceExpression(_)
1009+
| AstKind::AssignmentExpression(_)
1010+
| AstKind::LogicalExpression(_)
1011+
| AstKind::BinaryExpression(_)
1012+
| AstKind::ConditionalExpression(_)
1013+
| AstKind::AwaitExpression(_)
1014+
| AstKind::ParenthesizedExpression(_)
1015+
| AstKind::TSAsExpression(_)
1016+
| AstKind::TSSatisfiesExpression(_)
1017+
| AstKind::TSNonNullExpression(_)
1018+
| AstKind::TSTypeAssertion(_)
1019+
| AstKind::TSInstantiationExpression(_) => {}
1020+
_ => return false,
1021+
}
1022+
}
1023+
1024+
let Some(expr_stmt_span) = expr_stmt_span else {
1025+
return false;
1026+
};
1027+
1028+
// Node must be at the start of the statement for ASI hazard to apply
1029+
if node_span.start != expr_stmt_span.start {
1030+
return false;
1031+
}
1032+
1033+
if expr_stmt_span.start == 0 {
1034+
return false;
1035+
}
1036+
1037+
let source_text = ctx.source_text();
1038+
let last_char = find_last_meaningful_char(source_text, expr_stmt_span.start, ctx);
1039+
1040+
let Some(last_char) = last_char else {
1041+
return false;
1042+
};
1043+
1044+
// Characters that could cause ASI issues when followed by `[`, `(`, `/`, etc.
1045+
matches!(last_char, ')' | ']' | '}' | '"' | '\'' | '`' | '+' | '-' | '/' | '.')
1046+
|| last_char.is_alphanumeric()
1047+
|| last_char == '_'
1048+
|| last_char == '$'
1049+
}
1050+
1051+
#[inline]
1052+
#[expect(clippy::cast_possible_wrap)]
1053+
fn is_utf8_char_boundary(b: u8) -> bool {
1054+
(b as i8) >= -0x40
1055+
}
1056+
1057+
/// Find the last meaningful (non-whitespace, non-comment) character before `end_pos`.
1058+
fn find_last_meaningful_char(source_text: &str, end_pos: u32, ctx: &LintContext) -> Option<char> {
1059+
let bytes = source_text.as_bytes();
1060+
let comments = ctx.semantic().comments();
1061+
1062+
let mut comment_idx = comments.partition_point(|c| c.span.start < end_pos);
1063+
let mut current_comment_end: u32 = 0;
1064+
let mut i = end_pos;
1065+
1066+
// Handle case where end_pos is inside a comment
1067+
if comment_idx > 0 {
1068+
let prev_comment = &comments[comment_idx - 1];
1069+
if end_pos <= prev_comment.span.end {
1070+
i = prev_comment.span.start;
1071+
comment_idx -= 1;
1072+
if comment_idx > 0 {
1073+
current_comment_end = comments[comment_idx - 1].span.end;
1074+
}
1075+
}
1076+
}
1077+
1078+
while i > 0 {
1079+
if i <= current_comment_end && comment_idx > 0 {
1080+
comment_idx -= 1;
1081+
current_comment_end = comments[comment_idx].span.start;
1082+
i = current_comment_end;
1083+
continue;
1084+
}
1085+
1086+
i -= 1;
1087+
1088+
let byte = bytes[i as usize];
1089+
1090+
if byte.is_ascii_whitespace() {
1091+
continue;
1092+
}
1093+
1094+
// Check if we're entering a comment from the end
1095+
if comment_idx > 0 {
1096+
let comment = &comments[comment_idx - 1];
1097+
if i >= comment.span.start && i < comment.span.end {
1098+
i = comment.span.start;
1099+
comment_idx -= 1;
1100+
if comment_idx > 0 {
1101+
current_comment_end = comments[comment_idx - 1].span.end;
1102+
}
1103+
continue;
1104+
}
1105+
}
1106+
1107+
if byte.is_ascii() {
1108+
return Some(byte as char);
1109+
}
1110+
1111+
// Multi-byte UTF-8: find the start byte (max 4 bytes per char)
1112+
let i_usize = i as usize;
1113+
let char_start = if is_utf8_char_boundary(bytes[i_usize.saturating_sub(1)]) {
1114+
i_usize - 1
1115+
} else if is_utf8_char_boundary(bytes[i_usize.saturating_sub(2)]) {
1116+
i_usize - 2
1117+
} else {
1118+
i_usize - 3
1119+
};
1120+
1121+
let c = source_text[char_start..].chars().next().unwrap();
1122+
1123+
// Skip irregular whitespace (NBSP, ZWNBSP, etc.)
1124+
if is_irregular_whitespace(c) {
1125+
#[expect(clippy::cast_possible_truncation)]
1126+
{
1127+
i = char_start as u32;
1128+
}
1129+
continue;
1130+
}
1131+
1132+
return Some(c);
1133+
}
1134+
1135+
None
1136+
}

crates/oxc_linter/src/rules/unicorn/prefer_spread.rs

Lines changed: 118 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,15 @@ impl Rule for PreferSpread {
5252
return;
5353
};
5454

55-
check_unicorn_prefer_spread(call_expr, ctx);
55+
check_unicorn_prefer_spread(node, call_expr, ctx);
5656
}
5757
}
5858

59-
fn check_unicorn_prefer_spread(call_expr: &CallExpression, ctx: &LintContext) {
59+
fn check_unicorn_prefer_spread<'a>(
60+
node: &AstNode<'a>,
61+
call_expr: &CallExpression<'a>,
62+
ctx: &LintContext<'a>,
63+
) {
6064
let Some(member_expr) = call_expr.callee.without_parentheses().as_member_expression() else {
6165
return;
6266
};
@@ -87,7 +91,7 @@ fn check_unicorn_prefer_spread(call_expr: &CallExpression, ctx: &LintContext) {
8791
return;
8892
}
8993

90-
report_with_spread_fixer(ctx, call_expr.span, "Array.from()", expr);
94+
report_with_spread_fixer(node, ctx, call_expr.span, "Array.from()", expr);
9195
}
9296
// `array.concat()`
9397
"concat" => {
@@ -131,7 +135,7 @@ fn check_unicorn_prefer_spread(call_expr: &CallExpression, ctx: &LintContext) {
131135
}
132136
}
133137

134-
report_with_spread_fixer(ctx, call_expr.span, "array.slice()", member_expr_obj);
138+
report_with_spread_fixer(node, ctx, call_expr.span, "array.slice()", member_expr_obj);
135139
}
136140
// `array.toSpliced()`
137141
"toSpliced" => {
@@ -145,6 +149,7 @@ fn check_unicorn_prefer_spread(call_expr: &CallExpression, ctx: &LintContext) {
145149
}
146150

147151
report_with_spread_fixer(
152+
node,
148153
ctx,
149154
call_expr.span,
150155
"array.toSpliced()",
@@ -171,10 +176,15 @@ fn check_unicorn_prefer_spread(call_expr: &CallExpression, ctx: &LintContext) {
171176
ctx.diagnostic_with_fix(
172177
unicorn_prefer_spread_diagnostic(call_expr.span, "string.split()"),
173178
|fixer| {
179+
let needs_semi = ast_util::could_be_asi_hazard(node, ctx);
174180
let callee_obj = member_expr.object().without_parentheses();
181+
let prefix = if needs_semi { ";" } else { "" };
175182
fixer.replace(
176183
call_expr.span,
177-
format!("[...{}]", callee_obj.span().source_text(ctx.source_text())),
184+
format!(
185+
"{prefix}[...{}]",
186+
callee_obj.span().source_text(ctx.source_text())
187+
),
178188
)
179189
},
180190
);
@@ -241,13 +251,18 @@ fn is_not_array(expr: &Expression, ctx: &LintContext) -> bool {
241251
}
242252

243253
fn report_with_spread_fixer(
254+
node: &AstNode,
244255
ctx: &LintContext,
245256
span: Span,
246257
bad_method: &str,
247258
expr_to_spread: &Expression,
248259
) {
249260
ctx.diagnostic_with_fix(unicorn_prefer_spread_diagnostic(span, bad_method), |fixer| {
261+
let needs_semi = ast_util::could_be_asi_hazard(node, ctx);
250262
let mut codegen = fixer.codegen();
263+
if needs_semi {
264+
codegen.print_str(";");
265+
}
251266
codegen.print_str("[...");
252267
codegen.print_expression(expr_to_spread);
253268
codegen.print_str("]");
@@ -542,22 +557,118 @@ fn test() {
542557
// `Array.from()`
543558
("const x = Array.from(set);", "const x = [...set];", None),
544559
("Array.from(new Set([1, 2])).map(() => {});", "[...new Set([1, 2])].map(() => {});", None),
560+
// `Array.from()` - ASI hazard cases (need semicolon prefix)
561+
(
562+
"const foo = bar\nArray.from(set).map(() => {})",
563+
"const foo = bar\n;[...set].map(() => {})",
564+
None,
565+
),
566+
(
567+
"foo()\nArray.from(set).forEach(doSomething)",
568+
"foo()\n;[...set].forEach(doSomething)",
569+
None,
570+
),
571+
// `Array.from()` - No ASI hazard (semicolon already present)
572+
(
573+
"const foo = bar;\nArray.from(set).map(() => {})",
574+
"const foo = bar;\n[...set].map(() => {})",
575+
None,
576+
),
577+
// `Array.from()` - ASI hazard with comments before
578+
(
579+
"foo() /* comment */\nArray.from(set).map(() => {})",
580+
"foo() /* comment */\n;[...set].map(() => {})",
581+
None,
582+
),
583+
(
584+
"foo() // comment\nArray.from(set).map(() => {})",
585+
"foo() // comment\n;[...set].map(() => {})",
586+
None,
587+
),
545588
// `array.slice()`
546589
("array.slice()", "[...array]", None),
547590
("array.slice(1).slice()", "[...array.slice(1)]", None),
591+
// `array.slice()` - ASI hazard cases
592+
("foo()\narray.slice()", "foo()\n;[...array]", None),
548593
// `array.toSpliced()`
549594
("array.toSpliced()", "[...array]", None),
550595
("const copy = array.toSpliced()", "const copy = [...array]", None),
551-
// ("", "", None),
552-
// ("", "", None),
596+
// `array.toSpliced()` - ASI hazard cases
597+
("foo()\narray.toSpliced()", "foo()\n;[...array]", None),
553598
// `string.split()`
554599
(r#""🦄".split("")"#, r#"[..."🦄"]"#, None),
555600
(r#""foo bar baz".split("")"#, r#"[..."foo bar baz"]"#, None),
601+
// `string.split()` - ASI hazard cases
602+
("foo()\nstr.split(\"\")", "foo()\n;[...str]", None),
556603
(
557604
r"Array.from(path.matchAll(/\{([^{}?]+\??)\}/g))",
558605
"[...path.matchAll(/\\{([^{}?]+\\??)\\}/g)]",
559606
None,
560607
),
608+
// Cases where NO semicolon should be added (not an ExpressionStatement)
609+
("return Array.from(set)", "return [...set]", None),
610+
("const x = Array.from(set)", "const x = [...set]", None),
611+
("foo(Array.from(set))", "foo([...set])", None),
612+
("if (Array.from(set).length) {}", "if ([...set].length) {}", None),
613+
// `Array.from()` - ASI hazard with multi-byte Unicode identifiers
614+
("日本語\nArray.from(set).map(() => {})", "日本語\n;[...set].map(() => {})", None),
615+
(
616+
"const foo = 日本語\nArray.from(set).map(() => {})",
617+
"const foo = 日本語\n;[...set].map(() => {})",
618+
None,
619+
),
620+
("/**/Array.from(set).map(() => {})", "/**/[...set].map(() => {})", None),
621+
("/regex/\nArray.from(set).map(() => {})", "/regex/\n;[...set].map(() => {})", None),
622+
("/regex/g\nArray.from(set).map(() => {})", "/regex/g\n;[...set].map(() => {})", None),
623+
("0.\nArray.from(set).map(() => {})", "0.\n;[...set].map(() => {})", None),
624+
(
625+
"foo()\u{00A0}\nArray.from(set).map(() => {})",
626+
"foo()\u{00A0}\n;[...set].map(() => {})",
627+
None,
628+
),
629+
(
630+
"foo()\u{FEFF}\nArray.from(set).map(() => {})",
631+
"foo()\u{FEFF}\n;[...set].map(() => {})",
632+
None,
633+
),
634+
("foo() /* a */ /* b */\nArray.from(set)", "foo() /* a */ /* b */\n;[...set]", None),
635+
("x++\narray.slice()", "x++\n;[...array]", None),
636+
("x--\narray.slice()", "x--\n;[...array]", None),
637+
("arr[0]\narray.slice()", "arr[0]\n;[...array]", None),
638+
("obj.prop\narray.slice()", "obj.prop\n;[...array]", None),
639+
("while (array.slice().length) {}", "while ([...array].length) {}", None),
640+
("do {} while (array.slice().length)", "do {} while ([...array].length)", None),
641+
("for (array.slice();;) {}", "for ([...array];;) {}", None),
642+
("switch (array.slice()[0]) {}", "switch ([...array][0]) {}", None),
643+
("`template`\narray.toSpliced()", "`template`\n;[...array]", None),
644+
(
645+
r#"'string'
646+
str.split("")"#,
647+
"'string'\n;[...str]",
648+
None,
649+
),
650+
(
651+
r#""string"
652+
str.split("")"#,
653+
r#""string"
654+
;[...str]"#,
655+
None,
656+
),
657+
(
658+
"foo()\nArray.from(set).map(x => x).filter(Boolean).length",
659+
"foo()\n;[...set].map(x => x).filter(Boolean).length",
660+
None,
661+
),
662+
("const fn = () => Array.from(set)", "const fn = () => [...set]", None),
663+
("foo ? Array.from(a) : b", "foo ? [...a] : b", None),
664+
("foo || Array.from(set)", "foo || [...set]", None),
665+
("foo && Array.from(set)", "foo && [...set]", None),
666+
("foo + Array.from(set).length", "foo + [...set].length", None),
667+
("x = Array.from(set)", "x = [...set]", None),
668+
("const obj = { arr: Array.from(set) }", "const obj = { arr: [...set] }", None),
669+
("(foo, Array.from(set))", "(foo, [...set])", None),
670+
("[Array.from(set)]", "[[...set]]", None),
671+
("async () => await Array.from(set)", "async () => await [...set]", None),
561672
];
562673

563674
Tester::new(PreferSpread::NAME, PreferSpread::PLUGIN, pass, fail)

0 commit comments

Comments
 (0)