Skip to content

Commit b05be94

Browse files
committed
fix(language_server): append rule to existing disable comment instead of creating duplicate
When using the 'Disable rule for this line' quick fix, if there's already a disable-next-line comment on the line above, append the new rule to the existing comment using comma separation instead of creating a new duplicate comment. Fixes #16060
1 parent bab4bc8 commit b05be94

File tree

4 files changed

+169
-36
lines changed

4 files changed

+169
-36
lines changed

crates/oxc_language_server/src/linter/error_with_position.rs

Lines changed: 119 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use tower_lsp_server::lsp_types::{
77

88
use oxc_data_structures::rope::{Rope, get_line_column};
99
use oxc_diagnostics::{OxcCode, Severity};
10-
use oxc_linter::{Fix, Message, PossibleFixes};
10+
use oxc_linter::{DisableDirectives, Fix, Message, PossibleFixes, RuleCommentType};
1111

1212
#[derive(Debug, Clone, Default)]
1313
pub struct DiagnosticReport {
@@ -38,6 +38,7 @@ pub fn message_to_lsp_diagnostic(
3838
uri: &Uri,
3939
source_text: &str,
4040
rope: &Rope,
41+
directives: Option<&DisableDirectives>,
4142
) -> DiagnosticReport {
4243
let severity = match message.error.severity {
4344
Severity::Error => Some(lsp_types::DiagnosticSeverity::ERROR),
@@ -130,6 +131,7 @@ pub fn message_to_lsp_diagnostic(
130131
section_offset,
131132
rope,
132133
source_text,
134+
directives,
133135
);
134136

135137
DiagnosticReport { diagnostic, fixed_content }
@@ -203,6 +205,7 @@ fn add_ignore_fixes(
203205
section_offset: u32,
204206
rope: &Rope,
205207
source_text: &str,
208+
directives: Option<&DisableDirectives>,
206209
) -> PossibleFixContent {
207210
// do not append ignore code actions when the error is the ignore action
208211
if matches!(fixes, PossibleFixContent::Single(ref fix) if fix.message.as_ref().is_some_and(|message| message.starts_with("remove unused disable directive")))
@@ -218,13 +221,13 @@ fn add_ignore_fixes(
218221
}
219222

220223
if let Some(rule_name) = code.number.as_ref() {
221-
// TODO: doesn't support disabling multiple rules by name for a given line.
222224
new_fixes.push(disable_for_this_line(
223225
rule_name,
224226
error_offset,
225227
section_offset,
226228
rope,
227229
source_text,
230+
directives,
228231
));
229232
new_fixes.push(disable_for_this_section(rule_name, section_offset, rope, source_text));
230233
}
@@ -244,6 +247,76 @@ fn disable_for_this_line(
244247
section_offset: u32,
245248
rope: &Rope,
246249
source_text: &str,
250+
directives: Option<&DisableDirectives>,
251+
) -> FixedContent {
252+
if let Some(directives) = directives
253+
&& let Some(existing_comment) =
254+
directives.find_disable_next_line_comment_for_position(error_offset)
255+
{
256+
return append_rule_to_existing_comment(rule_name, existing_comment, rope, source_text);
257+
}
258+
259+
create_new_disable_comment(rule_name, error_offset, section_offset, rope, source_text)
260+
}
261+
262+
/// Append a rule to an existing disable-next-line comment, or return a no-op if the rule already exists.
263+
fn append_rule_to_existing_comment(
264+
rule_name: &str,
265+
existing_comment: &oxc_linter::DisableRuleComment,
266+
rope: &Rope,
267+
source_text: &str,
268+
) -> FixedContent {
269+
let comment_span = existing_comment.span;
270+
let comment_text = &source_text[comment_span.start as usize..comment_span.end as usize];
271+
272+
// Get the existing rules from the comment
273+
let existing_rules: Vec<&str> = match &existing_comment.r#type {
274+
RuleCommentType::All => {
275+
// If it's an "all" directive, just return a no-op (can't add more rules)
276+
let start_position = offset_to_position(rope, comment_span.start, source_text);
277+
let end_position = offset_to_position(rope, comment_span.end, source_text);
278+
return FixedContent {
279+
message: Some(format!("Disable {rule_name} for this line")),
280+
code: comment_text.to_string(),
281+
range: Range::new(start_position, end_position),
282+
};
283+
}
284+
RuleCommentType::Single(rules) => rules.iter().map(|r| r.rule_name.as_str()).collect(),
285+
};
286+
287+
// Check if the rule is already in the comment
288+
if existing_rules.contains(&rule_name) {
289+
// Rule already exists, return a no-op fix (same content)
290+
let start_position = offset_to_position(rope, comment_span.start, source_text);
291+
let end_position = offset_to_position(rope, comment_span.end, source_text);
292+
return FixedContent {
293+
message: Some(format!("Disable {rule_name} for this line")),
294+
code: comment_text.to_string(),
295+
range: Range::new(start_position, end_position),
296+
};
297+
}
298+
299+
// Append the new rule to the comment using comma separation (ESLint standard format)
300+
// The comment_text is just the content inside the comment (without // prefix for line comments)
301+
let new_comment = format!("{comment_text}, {rule_name}");
302+
303+
let start_position = offset_to_position(rope, comment_span.start, source_text);
304+
let end_position = offset_to_position(rope, comment_span.end, source_text);
305+
306+
FixedContent {
307+
message: Some(format!("Disable {rule_name} for this line")),
308+
code: new_comment,
309+
range: Range::new(start_position, end_position),
310+
}
311+
}
312+
313+
/// Create a new disable-next-line comment when no existing comment is found.
314+
fn create_new_disable_comment(
315+
rule_name: &str,
316+
error_offset: u32,
317+
section_offset: u32,
318+
rope: &Rope,
319+
source_text: &str,
247320
) -> FixedContent {
248321
let bytes = source_text.as_bytes();
249322
// Find the line break before the error
@@ -436,7 +509,7 @@ mod test {
436509
fn disable_for_this_line_single_line() {
437510
let source = "console.log('hello');";
438511
let rope = Rope::from_str(source);
439-
let fix = super::disable_for_this_line("no-console", 0, 0, &rope, source);
512+
let fix = super::disable_for_this_line("no-console", 0, 0, &rope, source, None);
440513

441514
assert_eq!(fix.code, "// oxlint-disable-next-line no-console\n");
442515
assert_eq!(fix.range.start.line, 0);
@@ -447,7 +520,7 @@ mod test {
447520
fn disable_for_this_line_with_spaces() {
448521
let source = " console.log('hello');";
449522
let rope = Rope::from_str(source);
450-
let fix = super::disable_for_this_line("no-console", 10, 0, &rope, source);
523+
let fix = super::disable_for_this_line("no-console", 10, 0, &rope, source, None);
451524

452525
assert_eq!(fix.code, " // oxlint-disable-next-line no-console\n");
453526
assert_eq!(fix.range.start.line, 0);
@@ -458,7 +531,7 @@ mod test {
458531
fn disable_for_this_line_with_tabs() {
459532
let source = "\t\tconsole.log('hello');";
460533
let rope = Rope::from_str(source);
461-
let fix = super::disable_for_this_line("no-console", 10, 0, &rope, source);
534+
let fix = super::disable_for_this_line("no-console", 10, 0, &rope, source, None);
462535

463536
assert_eq!(fix.code, "\t\t// oxlint-disable-next-line no-console\n");
464537
assert_eq!(fix.range.start.line, 0);
@@ -469,7 +542,7 @@ mod test {
469542
fn disable_for_this_line_mixed_tabs_spaces() {
470543
let source = "\t \tconsole.log('hello');";
471544
let rope = Rope::from_str(source);
472-
let fix = super::disable_for_this_line("no-console", 12, 0, &rope, source);
545+
let fix = super::disable_for_this_line("no-console", 12, 0, &rope, source, None);
473546

474547
assert_eq!(fix.code, "\t \t// oxlint-disable-next-line no-console\n");
475548
assert_eq!(fix.range.start.line, 0);
@@ -480,7 +553,7 @@ mod test {
480553
fn disable_for_this_line_multiline_with_tabs() {
481554
let source = "function test() {\n\tconsole.log('hello');\n}";
482555
let rope = Rope::from_str(source);
483-
let fix = super::disable_for_this_line("no-console", 27, 0, &rope, source);
556+
let fix = super::disable_for_this_line("no-console", 27, 0, &rope, source, None);
484557

485558
assert_eq!(fix.code, "\t// oxlint-disable-next-line no-console\n");
486559
assert_eq!(fix.range.start.line, 1);
@@ -491,7 +564,7 @@ mod test {
491564
fn disable_for_this_line_multiline_with_spaces() {
492565
let source = "function test() {\n console.log('hello');\n}";
493566
let rope = Rope::from_str(source);
494-
let fix = super::disable_for_this_line("no-console", 30, 0, &rope, source);
567+
let fix = super::disable_for_this_line("no-console", 30, 0, &rope, source, None);
495568

496569
assert_eq!(fix.code, " // oxlint-disable-next-line no-console\n");
497570
assert_eq!(fix.range.start.line, 1);
@@ -502,7 +575,7 @@ mod test {
502575
fn disable_for_this_line_complex_indentation() {
503576
let source = "function test() {\n\t \t console.log('hello');\n}";
504577
let rope = Rope::from_str(source);
505-
let fix = super::disable_for_this_line("no-console", 33, 0, &rope, source);
578+
let fix = super::disable_for_this_line("no-console", 33, 0, &rope, source, None);
506579

507580
assert_eq!(fix.code, "\t \t // oxlint-disable-next-line no-console\n");
508581
assert_eq!(fix.range.start.line, 1);
@@ -513,7 +586,7 @@ mod test {
513586
fn disable_for_this_line_no_indentation() {
514587
let source = "function test() {\nconsole.log('hello');\n}";
515588
let rope = Rope::from_str(source);
516-
let fix = super::disable_for_this_line("no-console", 26, 0, &rope, source);
589+
let fix = super::disable_for_this_line("no-console", 26, 0, &rope, source, None);
517590

518591
assert_eq!(fix.code, "// oxlint-disable-next-line no-console\n");
519592
assert_eq!(fix.range.start.line, 1);
@@ -524,7 +597,7 @@ mod test {
524597
fn disable_for_this_line_crlf_with_tabs() {
525598
let source = "function test() {\r\n\tconsole.log('hello');\r\n}";
526599
let rope = Rope::from_str(source);
527-
let fix = super::disable_for_this_line("no-console", 28, 0, &rope, source);
600+
let fix = super::disable_for_this_line("no-console", 28, 0, &rope, source, None);
528601

529602
assert_eq!(fix.code, "\t// oxlint-disable-next-line no-console\n");
530603
assert_eq!(fix.range.start.line, 1);
@@ -535,7 +608,7 @@ mod test {
535608
fn disable_for_this_line_deeply_nested() {
536609
let source = "if (true) {\n\t\tif (nested) {\n\t\t\tconsole.log('deep');\n\t\t}\n}";
537610
let rope = Rope::from_str(source);
538-
let fix = super::disable_for_this_line("no-console", 40, 0, &rope, source);
611+
let fix = super::disable_for_this_line("no-console", 40, 0, &rope, source, None);
539612

540613
assert_eq!(fix.code, "\t\t\t// oxlint-disable-next-line no-console\n");
541614
assert_eq!(fix.range.start.line, 2);
@@ -546,7 +619,7 @@ mod test {
546619
fn disable_for_this_line_at_start_of_file() {
547620
let source = "console.log('hello');";
548621
let rope = Rope::from_str(source);
549-
let fix = super::disable_for_this_line("no-console", 0, 0, &rope, source);
622+
let fix = super::disable_for_this_line("no-console", 0, 0, &rope, source, None);
550623

551624
assert_eq!(fix.code, "// oxlint-disable-next-line no-console\n");
552625
assert_eq!(fix.range.start.line, 0);
@@ -559,7 +632,7 @@ mod test {
559632
let source = "function test() {\n \tcode \there\n}";
560633
let rope = Rope::from_str(source);
561634
// Error at position of 'code' (after " \t")
562-
let fix = super::disable_for_this_line("no-console", 21, 0, &rope, source);
635+
let fix = super::disable_for_this_line("no-console", 21, 0, &rope, source, None);
563636

564637
// Should only capture " \t" at the beginning, not the spaces around "here"
565638
assert_eq!(fix.code, " \t// oxlint-disable-next-line no-console\n");
@@ -574,8 +647,14 @@ mod test {
574647
let rope = Rope::from_str(source);
575648
let section_offset = 8; // At the \n after "<script>"
576649
let error_offset = 17; // At 'console'
577-
let fix =
578-
super::disable_for_this_line("no-console", error_offset, section_offset, &rope, source);
650+
let fix = super::disable_for_this_line(
651+
"no-console",
652+
error_offset,
653+
section_offset,
654+
&rope,
655+
source,
656+
None,
657+
);
579658

580659
assert_eq!(fix.code, "// oxlint-disable-next-line no-console\n");
581660
assert_eq!(fix.range.start.line, 1);
@@ -589,8 +668,14 @@ mod test {
589668
let rope = Rope::from_str(source);
590669
let section_offset = 8; // After "<script>"
591670
let error_offset = 16; // At 'console'
592-
let fix =
593-
super::disable_for_this_line("no-console", error_offset, section_offset, &rope, source);
671+
let fix = super::disable_for_this_line(
672+
"no-console",
673+
error_offset,
674+
section_offset,
675+
&rope,
676+
source,
677+
None,
678+
);
594679

595680
assert_eq!(fix.code, "\n// oxlint-disable-next-line no-console\n");
596681
assert_eq!(fix.range.start.line, 0);
@@ -604,8 +689,14 @@ mod test {
604689
let rope = Rope::from_str(source);
605690
let section_offset = 31; // At \n after "<script>"
606691
let error_offset = 36; // At 'console' (after " ")
607-
let fix =
608-
super::disable_for_this_line("no-console", error_offset, section_offset, &rope, source);
692+
let fix = super::disable_for_this_line(
693+
"no-console",
694+
error_offset,
695+
section_offset,
696+
&rope,
697+
source,
698+
None,
699+
);
609700

610701
assert_eq!(fix.code, " // oxlint-disable-next-line no-console\n");
611702
assert_eq!(fix.range.start.line, 3);
@@ -619,8 +710,14 @@ mod test {
619710
let rope = Rope::from_str(source);
620711
let section_offset = 8; // At the \n after "<script>"
621712
let error_offset = 8; // Error exactly at section offset
622-
let fix =
623-
super::disable_for_this_line("no-console", error_offset, section_offset, &rope, source);
713+
let fix = super::disable_for_this_line(
714+
"no-console",
715+
error_offset,
716+
section_offset,
717+
&rope,
718+
source,
719+
None,
720+
);
624721

625722
assert_eq!(fix.code, "// oxlint-disable-next-line no-console\n");
626723
assert_eq!(fix.range.start.line, 1);

crates/oxc_language_server/src/linter/isolated_lint_handler.rs

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -123,21 +123,25 @@ impl IsolatedLintHandler {
123123

124124
let fs = IsolatedLintHandlerFileSystem::new(path.to_path_buf(), Arc::from(source_text));
125125

126-
let mut messages: Vec<DiagnosticReport> = self
127-
.runner
128-
.run_source(&Arc::from(path.as_os_str()), source_text.to_string(), &fs)
126+
let lint_messages =
127+
self.runner.run_source(&Arc::from(path.as_os_str()), source_text.to_string(), &fs);
128+
129+
let directives = self.runner.directives_coordinator().get(path);
130+
131+
let mut messages: Vec<DiagnosticReport> = lint_messages
129132
.iter()
130-
.map(|message| message_to_lsp_diagnostic(message, uri, source_text, rope))
133+
.map(|message| {
134+
message_to_lsp_diagnostic(message, uri, source_text, rope, directives.as_ref())
135+
})
131136
.collect();
132137

133-
// Add unused directives if configured
134138
if let Some(severity) = self.unused_directives_severity
135-
&& let Some(directives) = self.runner.directives_coordinator().get(path)
139+
&& let Some(directives) = &directives
136140
{
137141
messages.extend(
138-
create_unused_directives_messages(&directives, severity, source_text)
139-
.iter()
140-
.map(|message| message_to_lsp_diagnostic(message, uri, source_text, rope)),
142+
create_unused_directives_messages(directives, severity, source_text).iter().map(
143+
|message| message_to_lsp_diagnostic(message, uri, source_text, rope, None),
144+
),
141145
);
142146
}
143147

crates/oxc_language_server/src/linter/snapshots/fixtures_linter_unused_disabled_directives@test.js.snap

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -66,15 +66,15 @@ Title: Disable no-console for this line
6666
TextEdit: TextEdit {
6767
range: Range {
6868
start: Position {
69-
line: 9,
70-
character: 0,
69+
line: 8,
70+
character: 2,
7171
},
7272
end: Position {
73-
line: 9,
74-
character: 0,
73+
line: 8,
74+
character: 52,
7575
},
7676
},
77-
new_text: "// oxlint-disable-next-line no-console\n",
77+
new_text: " oxlint-disable-next-line no-debugger, no-for-loop, no-console",
7878
}
7979

8080

0 commit comments

Comments
 (0)