Skip to content

Commit 654c8bd

Browse files
committed
fix(language_server): add whitespace for // oxlint-disable-next-line fix
1 parent 1986e0f commit 654c8bd

File tree

6 files changed

+269
-36
lines changed

6 files changed

+269
-36
lines changed

crates/oxc_language_server/src/linter/error_with_position.rs

Lines changed: 256 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,6 @@ impl<'a> SpanPositionMessage<'a> {
187187
Self { start, end, message: None }
188188
}
189189

190-
#[must_use]
191190
pub fn with_message(mut self, message: Option<Cow<'a, str>>) -> Self {
192191
self.message = message;
193192
self
@@ -362,7 +361,13 @@ fn add_ignore_fixes<'a>(
362361

363362
if let Some(rule_name) = code.number.as_ref() {
364363
// TODO: doesn't support disabling multiple rules by name for a given line.
365-
new_fixes.push(disable_for_this_line(rule_name, error_offset, rope, source_text));
364+
new_fixes.push(disable_for_this_line(
365+
rule_name,
366+
error_offset,
367+
section_offset,
368+
rope,
369+
source_text,
370+
));
366371
new_fixes.push(disable_for_this_section(rule_name, section_offset, rope, source_text));
367372
}
368373

@@ -378,15 +383,43 @@ fn add_ignore_fixes<'a>(
378383
fn disable_for_this_line<'a>(
379384
rule_name: &str,
380385
error_offset: u32,
386+
section_offset: u32,
381387
rope: &Rope,
382388
source_text: &str,
383389
) -> FixWithPosition<'a> {
384-
let mut start_position = offset_to_position(rope, error_offset, source_text);
385-
start_position.character = 0; // TODO: character should be set to match the first non-whitespace character in the source text to match the existing indentation.
386-
let end_position = start_position.clone();
390+
let bytes = source_text.as_bytes();
391+
// Find the line break before the error
392+
let mut line_break_offset = error_offset;
393+
for byte in bytes[section_offset as usize..error_offset as usize].iter().rev() {
394+
if *byte == b'\n' || *byte == b'\r' {
395+
break;
396+
}
397+
line_break_offset -= 1;
398+
}
399+
400+
// For framework files, ensure we don't go before the section start
401+
if section_offset > 0 && line_break_offset < section_offset {
402+
line_break_offset = section_offset;
403+
}
404+
405+
let (content_prefix, insert_offset) =
406+
get_section_insert_position(section_offset, line_break_offset, bytes);
407+
408+
let whitespace_range = {
409+
let start = insert_offset as usize;
410+
let end = error_offset as usize;
411+
let slice = &bytes[start..end];
412+
let whitespace_len = slice.iter().take_while(|c| matches!(c, b' ' | b'\t')).count();
413+
&slice[..whitespace_len]
414+
};
415+
let whitespace_string = String::from_utf8_lossy(whitespace_range);
416+
417+
let position = offset_to_position(rope, insert_offset, source_text);
387418
FixWithPosition {
388-
content: Cow::Owned(format!("// oxlint-disable-next-line {rule_name}\n")),
389-
span: SpanPositionMessage::new(start_position, end_position)
419+
content: Cow::Owned(format!(
420+
"{content_prefix}{whitespace_string}// oxlint-disable-next-line {rule_name}\n"
421+
)),
422+
span: SpanPositionMessage::new(position.clone(), position)
390423
.with_message(Some(Cow::Owned(format!("Disable {rule_name} for this line")))),
391424
}
392425
}
@@ -399,37 +432,57 @@ fn disable_for_this_section<'a>(
399432
) -> FixWithPosition<'a> {
400433
let comment = format!("// oxlint-disable {rule_name}\n");
401434

402-
let (content, offset) = if section_offset == 0 {
403-
// JS files - insert at the beginning
404-
(Cow::Owned(comment), section_offset)
405-
} else {
435+
let (content_prefix, insert_offset) =
436+
get_section_insert_position(section_offset, section_offset, source_text.as_bytes());
437+
438+
let content = Cow::Owned(format!("{content_prefix}{comment}"));
439+
let position = offset_to_position(rope, insert_offset, source_text);
440+
441+
FixWithPosition {
442+
content,
443+
span: SpanPositionMessage::new(position.clone(), position)
444+
.with_message(Some(Cow::Owned(format!("Disable {rule_name} for this file")))),
445+
}
446+
}
447+
448+
/// Get the insert position and content prefix for section-based insertions.
449+
///
450+
/// For framework files (section_offset > 0), this handles proper line break detection.
451+
/// For regular JS files (section_offset == 0), it returns the offset as-is.
452+
///
453+
/// Returns (content_prefix, insert_offset) where:
454+
/// - content_prefix: "\n" if we need to add a line break, "" otherwise
455+
/// - insert_offset: the byte offset where the content should be inserted
456+
fn get_section_insert_position(
457+
section_offset: u32,
458+
target_offset: u32,
459+
bytes: &[u8],
460+
) -> (&'static str, u32) {
461+
if section_offset == 0 {
462+
// Regular JS files - insert at target offset
463+
("", target_offset)
464+
} else if target_offset == section_offset {
406465
// Framework files - check for line breaks at section_offset
407-
let bytes = source_text.as_bytes();
408466
let current = bytes.get(section_offset as usize);
409467
let next = bytes.get((section_offset + 1) as usize);
410468

411469
match (current, next) {
412470
(Some(b'\n'), _) => {
413471
// LF at offset, insert after it
414-
(Cow::Owned(comment), section_offset + 1)
472+
("", section_offset + 1)
415473
}
416474
(Some(b'\r'), Some(b'\n')) => {
417475
// CRLF at offset, insert after both
418-
(Cow::Owned(comment), section_offset + 2)
476+
("", section_offset + 2)
419477
}
420478
_ => {
421479
// Not at line start, prepend newline
422-
(Cow::Owned("\n".to_owned() + &comment), section_offset)
480+
("\n", section_offset)
423481
}
424482
}
425-
};
426-
427-
let position = offset_to_position(rope, offset, source_text);
428-
429-
FixWithPosition {
430-
content,
431-
span: SpanPositionMessage::new(position.clone(), position)
432-
.with_message(Some(Cow::Owned(format!("Disable {rule_name} for this file")))),
483+
} else {
484+
// Framework files where target_offset != section_offset (line was found)
485+
("", target_offset)
433486
}
434487
}
435488

@@ -518,6 +571,186 @@ mod test {
518571
assert_eq!(fix.span.start.character, 6);
519572
}
520573

574+
#[test]
575+
fn disable_for_this_line_single_line() {
576+
let source = "console.log('hello');";
577+
let rope = Rope::from_str(source);
578+
let fix = super::disable_for_this_line("no-console", 0, 0, &rope, source);
579+
580+
assert_eq!(fix.content, "// oxlint-disable-next-line no-console\n");
581+
assert_eq!(fix.span.start.line, 0);
582+
assert_eq!(fix.span.start.character, 0);
583+
}
584+
585+
#[test]
586+
fn disable_for_this_line_with_spaces() {
587+
let source = " console.log('hello');";
588+
let rope = Rope::from_str(source);
589+
let fix = super::disable_for_this_line("no-console", 10, 0, &rope, source);
590+
591+
assert_eq!(fix.content, " // oxlint-disable-next-line no-console\n");
592+
assert_eq!(fix.span.start.line, 0);
593+
assert_eq!(fix.span.start.character, 0);
594+
}
595+
596+
#[test]
597+
fn disable_for_this_line_with_tabs() {
598+
let source = "\t\tconsole.log('hello');";
599+
let rope = Rope::from_str(source);
600+
let fix = super::disable_for_this_line("no-console", 10, 0, &rope, source);
601+
602+
assert_eq!(fix.content, "\t\t// oxlint-disable-next-line no-console\n");
603+
assert_eq!(fix.span.start.line, 0);
604+
assert_eq!(fix.span.start.character, 0);
605+
}
606+
607+
#[test]
608+
fn disable_for_this_line_mixed_tabs_spaces() {
609+
let source = "\t \tconsole.log('hello');";
610+
let rope = Rope::from_str(source);
611+
let fix = super::disable_for_this_line("no-console", 12, 0, &rope, source);
612+
613+
assert_eq!(fix.content, "\t \t// oxlint-disable-next-line no-console\n");
614+
assert_eq!(fix.span.start.line, 0);
615+
assert_eq!(fix.span.start.character, 0);
616+
}
617+
618+
#[test]
619+
fn disable_for_this_line_multiline_with_tabs() {
620+
let source = "function test() {\n\tconsole.log('hello');\n}";
621+
let rope = Rope::from_str(source);
622+
let fix = super::disable_for_this_line("no-console", 27, 0, &rope, source);
623+
624+
assert_eq!(fix.content, "\t// oxlint-disable-next-line no-console\n");
625+
assert_eq!(fix.span.start.line, 1);
626+
assert_eq!(fix.span.start.character, 0);
627+
}
628+
629+
#[test]
630+
fn disable_for_this_line_multiline_with_spaces() {
631+
let source = "function test() {\n console.log('hello');\n}";
632+
let rope = Rope::from_str(source);
633+
let fix = super::disable_for_this_line("no-console", 30, 0, &rope, source);
634+
635+
assert_eq!(fix.content, " // oxlint-disable-next-line no-console\n");
636+
assert_eq!(fix.span.start.line, 1);
637+
assert_eq!(fix.span.start.character, 0);
638+
}
639+
640+
#[test]
641+
fn disable_for_this_line_complex_indentation() {
642+
let source = "function test() {\n\t \t console.log('hello');\n}";
643+
let rope = Rope::from_str(source);
644+
let fix = super::disable_for_this_line("no-console", 33, 0, &rope, source);
645+
646+
assert_eq!(fix.content, "\t \t // oxlint-disable-next-line no-console\n");
647+
assert_eq!(fix.span.start.line, 1);
648+
assert_eq!(fix.span.start.character, 0);
649+
}
650+
651+
#[test]
652+
fn disable_for_this_line_no_indentation() {
653+
let source = "function test() {\nconsole.log('hello');\n}";
654+
let rope = Rope::from_str(source);
655+
let fix = super::disable_for_this_line("no-console", 26, 0, &rope, source);
656+
657+
assert_eq!(fix.content, "// oxlint-disable-next-line no-console\n");
658+
assert_eq!(fix.span.start.line, 1);
659+
assert_eq!(fix.span.start.character, 0);
660+
}
661+
662+
#[test]
663+
fn disable_for_this_line_crlf_with_tabs() {
664+
let source = "function test() {\r\n\tconsole.log('hello');\r\n}";
665+
let rope = Rope::from_str(source);
666+
let fix = super::disable_for_this_line("no-console", 28, 0, &rope, source);
667+
668+
assert_eq!(fix.content, "\t// oxlint-disable-next-line no-console\n");
669+
assert_eq!(fix.span.start.line, 1);
670+
assert_eq!(fix.span.start.character, 0);
671+
}
672+
673+
#[test]
674+
fn disable_for_this_line_deeply_nested() {
675+
let source = "if (true) {\n\t\tif (nested) {\n\t\t\tconsole.log('deep');\n\t\t}\n}";
676+
let rope = Rope::from_str(source);
677+
let fix = super::disable_for_this_line("no-console", 40, 0, &rope, source);
678+
679+
assert_eq!(fix.content, "\t\t\t// oxlint-disable-next-line no-console\n");
680+
assert_eq!(fix.span.start.line, 2);
681+
assert_eq!(fix.span.start.character, 0);
682+
}
683+
684+
#[test]
685+
fn disable_for_this_line_at_start_of_file() {
686+
let source = "console.log('hello');";
687+
let rope = Rope::from_str(source);
688+
let fix = super::disable_for_this_line("no-console", 0, 0, &rope, source);
689+
690+
assert_eq!(fix.content, "// oxlint-disable-next-line no-console\n");
691+
assert_eq!(fix.span.start.line, 0);
692+
assert_eq!(fix.span.start.character, 0);
693+
}
694+
695+
#[test]
696+
fn disable_for_this_line_whitespace_only_continuous() {
697+
// Test that only continuous whitespace from line start is captured
698+
let source = "function test() {\n \tcode \there\n}";
699+
let rope = Rope::from_str(source);
700+
// Error at position of 'code' (after " \t")
701+
let fix = super::disable_for_this_line("no-console", 21, 0, &rope, source);
702+
703+
// Should only capture " \t" at the beginning, not the spaces around "here"
704+
assert_eq!(fix.content, " \t// oxlint-disable-next-line no-console\n");
705+
assert_eq!(fix.span.start.line, 1);
706+
assert_eq!(fix.span.start.character, 0);
707+
}
708+
709+
#[test]
710+
fn disable_for_this_line_with_section_offset() {
711+
// Test framework file with section offset (like Vue/Svelte)
712+
let source = "<script>\nconsole.log('hello');\n</script>";
713+
let rope = Rope::from_str(source);
714+
let section_offset = 8; // At the \n after "<script>"
715+
let error_offset = 17; // At 'console'
716+
let fix =
717+
super::disable_for_this_line("no-console", error_offset, section_offset, &rope, source);
718+
719+
assert_eq!(fix.content, "// oxlint-disable-next-line no-console\n");
720+
assert_eq!(fix.span.start.line, 1);
721+
assert_eq!(fix.span.start.character, 0);
722+
}
723+
724+
#[test]
725+
fn disable_for_this_line_section_offset_mid_line() {
726+
// Test framework file where section starts mid-line
727+
let source = "<script>console.log('hello');\n</script>";
728+
let rope = Rope::from_str(source);
729+
let section_offset = 8; // After "<script>"
730+
let error_offset = 16; // At 'console'
731+
let fix =
732+
super::disable_for_this_line("no-console", error_offset, section_offset, &rope, source);
733+
734+
assert_eq!(fix.content, "\n// oxlint-disable-next-line no-console\n");
735+
assert_eq!(fix.span.start.line, 0);
736+
assert_eq!(fix.span.start.character, 8);
737+
}
738+
739+
#[test]
740+
fn disable_for_this_line_section_offset_with_indentation() {
741+
// Test framework file with indented code
742+
let source = "<template>\n</template>\n<script>\n console.log('hello');\n</script>";
743+
let rope = Rope::from_str(source);
744+
let section_offset = 31; // At \n after "<script>"
745+
let error_offset = 36; // At 'console' (after " ")
746+
let fix =
747+
super::disable_for_this_line("no-console", error_offset, section_offset, &rope, source);
748+
749+
assert_eq!(fix.content, " // oxlint-disable-next-line no-console\n");
750+
assert_eq!(fix.span.start.line, 3);
751+
assert_eq!(fix.span.start.character, 0);
752+
}
753+
521754
fn assert_position(source: &str, offset: u32, expected: (u32, u32)) {
522755
let position = offset_to_position(&Rope::from_str(source), offset, source);
523756
assert_eq!(position.line, expected.0);

crates/oxc_language_server/src/snapshots/[email protected]

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ fixed: Multiple(
101101
message: Some(
102102
"Disable no-debugger for this line",
103103
),
104-
code: "// oxlint-disable-next-line no-debugger\n",
104+
code: " // oxlint-disable-next-line no-debugger\n",
105105
range: Range {
106106
start: Position {
107107
line: 10,
@@ -165,7 +165,7 @@ fixed: Multiple(
165165
message: Some(
166166
"Disable no-debugger for this line",
167167
),
168-
code: "// oxlint-disable-next-line no-debugger\n",
168+
code: " // oxlint-disable-next-line no-debugger\n",
169169
range: Range {
170170
start: Position {
171171
line: 14,
@@ -229,7 +229,7 @@ fixed: Multiple(
229229
message: Some(
230230
"Disable no-debugger for this line",
231231
),
232-
code: "// oxlint-disable-next-line no-debugger\n",
232+
code: " // oxlint-disable-next-line no-debugger\n",
233233
range: Range {
234234
start: Position {
235235
line: 18,

crates/oxc_language_server/src/snapshots/[email protected]

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ fixed: Multiple(
2121
message: Some(
2222
"Disable no-extra-boolean-cast for this line",
2323
),
24-
code: "// oxlint-disable-next-line no-extra-boolean-cast\n",
24+
code: " // oxlint-disable-next-line no-extra-boolean-cast\n",
2525
range: Range {
2626
start: Position {
2727
line: 3,
@@ -72,7 +72,7 @@ fixed: Multiple(
7272
message: Some(
7373
"Disable no-non-null-asserted-optional-chain for this line",
7474
),
75-
code: "// oxlint-disable-next-line no-non-null-asserted-optional-chain\n",
75+
code: " // oxlint-disable-next-line no-non-null-asserted-optional-chain\n",
7676
range: Range {
7777
start: Position {
7878
line: 11,

0 commit comments

Comments
 (0)