diff --git a/Cargo.lock b/Cargo.lock index a1c2b49fa2f9a..0e684d26157a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2161,6 +2161,7 @@ dependencies = [ "oxc_formatter", "oxc_linter", "oxc_parser 0.94.0", + "oxc_span 0.94.0", "papaya", "rustc-hash", "serde", diff --git a/crates/oxc_language_server/Cargo.toml b/crates/oxc_language_server/Cargo.toml index 9d1c2eab0d154..cbf20bc35f808 100644 --- a/crates/oxc_language_server/Cargo.toml +++ b/crates/oxc_language_server/Cargo.toml @@ -28,6 +28,7 @@ oxc_diagnostics = { workspace = true } oxc_formatter = { workspace = true } oxc_linter = { workspace = true, features = ["language_server"] } oxc_parser = { workspace = true } +oxc_span = { workspace = true } # env_logger = { workspace = true, features = ["humantime"] } diff --git a/crates/oxc_language_server/src/linter/error_with_position.rs b/crates/oxc_language_server/src/linter/error_with_position.rs index 5285783bf797f..3fa703398b55c 100644 --- a/crates/oxc_language_server/src/linter/error_with_position.rs +++ b/crates/oxc_language_server/src/linter/error_with_position.rs @@ -1,14 +1,14 @@ use std::{borrow::Cow, str::FromStr}; -use oxc_linter::{ - FixWithPosition, MessageWithPosition, PossibleFixesWithPosition, SpanPositionMessage, -}; use tower_lsp_server::lsp_types::{ self, CodeDescription, DiagnosticRelatedInformation, DiagnosticSeverity, NumberOrString, Position, Range, Uri, }; -use oxc_diagnostics::Severity; +use oxc_data_structures::rope::{Rope, get_line_column}; +use oxc_diagnostics::{OxcCode, OxcDiagnostic, Severity}; +use oxc_linter::{Fix, Message, PossibleFixes}; +use oxc_span::GetSpan; #[derive(Debug, Clone, Default)] pub struct DiagnosticReport { @@ -171,3 +171,356 @@ pub fn generate_inverted_diagnostics( } inverted_diagnostics } + +#[derive(Clone, Debug)] +pub struct SpanPositionMessage<'a> { + /// A brief suggestion message describing the fix. Will be shown in + /// editors via code actions. + message: Option>, + + start: SpanPosition, + end: SpanPosition, +} + +impl<'a> SpanPositionMessage<'a> { + pub fn new(start: SpanPosition, end: SpanPosition) -> Self { + Self { start, end, message: None } + } + + #[must_use] + pub fn with_message(mut self, message: Option>) -> Self { + self.message = message; + self + } + + pub fn start(&self) -> &SpanPosition { + &self.start + } + + pub fn end(&self) -> &SpanPosition { + &self.end + } + + pub fn message(&self) -> Option<&Cow<'a, str>> { + self.message.as_ref() + } +} + +#[derive(Clone, Debug, Default)] +pub struct SpanPosition { + pub line: u32, + pub character: u32, +} + +impl SpanPosition { + pub fn new(line: u32, column: u32) -> Self { + Self { line, character: column } + } +} + +pub fn offset_to_position(rope: &Rope, offset: u32, source_text: &str) -> SpanPosition { + let (line, column) = get_line_column(rope, offset, source_text); + SpanPosition::new(line, column) +} + +#[derive(Debug)] +pub struct MessageWithPosition<'a> { + pub message: Cow<'a, str>, + pub labels: Option>>, + pub help: Option>, + pub severity: Severity, + pub code: OxcCode, + pub url: Option>, + pub fixes: PossibleFixesWithPosition<'a>, +} + +// clippy: the source field is checked and assumed to be less than 4GB, and +// we assume that the fix offset will not exceed 2GB in either direction +#[expect(clippy::cast_possible_truncation)] +pub fn oxc_diagnostic_to_message_with_position<'a>( + diagnostic: OxcDiagnostic, + source_text: &str, + rope: &Rope, +) -> MessageWithPosition<'a> { + let inner = diagnostic.inner_owned(); + + let labels = inner.labels.as_ref().map(|labels| { + labels + .iter() + .map(|labeled_span| { + let offset = labeled_span.offset() as u32; + let start_position = offset_to_position(rope, offset, source_text); + let end_position = + offset_to_position(rope, offset + labeled_span.len() as u32, source_text); + let message = labeled_span.label().map(|label| Cow::Owned(label.to_string())); + + SpanPositionMessage::new(start_position, end_position).with_message(message) + }) + .collect::>() + }); + + MessageWithPosition { + message: inner.message, + severity: inner.severity, + help: inner.help, + url: inner.url, + code: inner.code, + labels, + fixes: PossibleFixesWithPosition::None, + } +} + +pub fn message_to_message_with_position<'a>( + message: Message<'a>, + source_text: &str, + rope: &Rope, +) -> MessageWithPosition<'a> { + let code = message.error.code.clone(); + let error_offset = message.span().start; + let section_offset = message.section_offset; + + let mut result = oxc_diagnostic_to_message_with_position(message.error, source_text, rope); + let fixes = match &message.fixes { + PossibleFixes::None => PossibleFixesWithPosition::None, + PossibleFixes::Single(fix) => { + PossibleFixesWithPosition::Single(fix_to_fix_with_position(fix, rope, source_text)) + } + PossibleFixes::Multiple(fixes) => PossibleFixesWithPosition::Multiple( + fixes.iter().map(|fix| fix_to_fix_with_position(fix, rope, source_text)).collect(), + ), + }; + + result.fixes = add_ignore_fixes(fixes, &code, error_offset, section_offset, rope, source_text); + + result +} + +/// Possible fixes with position information. +/// +/// This is similar to `PossibleFixes` but with position information. +/// It also includes "ignore this line" and "ignore this rule" fixes for the Language Server. +/// +/// The struct should be build with `message_to_message_with_position` +/// or `oxc_diagnostic_to_message_with_position` function to ensure the ignore fixes are added correctly. +#[derive(Debug)] +pub enum PossibleFixesWithPosition<'a> { + // No possible fixes. + // This happens on parser/semantic errors. + None, + // A single possible fix. + // This happens when a unused disable directive is reported. + Single(FixWithPosition<'a>), + // Multiple possible fixes. + // This happens when a lint reports a violation, then ignore fixes are added. + Multiple(Vec>), +} + +#[derive(Debug)] +pub struct FixWithPosition<'a> { + pub content: Cow<'a, str>, + pub span: SpanPositionMessage<'a>, +} + +fn fix_to_fix_with_position<'a>( + fix: &Fix<'a>, + rope: &Rope, + source_text: &str, +) -> FixWithPosition<'a> { + let start_position = offset_to_position(rope, fix.span.start, source_text); + let end_position = offset_to_position(rope, fix.span.end, source_text); + FixWithPosition { + content: fix.content.clone(), + span: SpanPositionMessage::new(start_position, end_position) + .with_message(fix.message.as_ref().map(|label| Cow::Owned(label.to_string()))), + } +} + +/// Add "ignore this line" and "ignore this rule" fixes to the existing fixes. +/// These fixes will be added to the end of the existing fixes. +/// If the existing fixes already contain an "remove unused disable directive" fix, +/// then no ignore fixes will be added. +fn add_ignore_fixes<'a>( + fixes: PossibleFixesWithPosition<'a>, + code: &OxcCode, + error_offset: u32, + section_offset: u32, + rope: &Rope, + source_text: &str, +) -> PossibleFixesWithPosition<'a> { + // do not append ignore code actions when the error is the ignore action + if matches!(fixes, PossibleFixesWithPosition::Single(ref fix) if fix.span.message.as_ref().is_some_and(|message| message.starts_with("remove unused disable directive"))) + { + return fixes; + } + + let mut new_fixes: Vec> = vec![]; + if let PossibleFixesWithPosition::Single(fix) = fixes { + new_fixes.push(fix); + } else if let PossibleFixesWithPosition::Multiple(existing_fixes) = fixes { + new_fixes.extend(existing_fixes); + } + + if let Some(rule_name) = code.number.as_ref() { + // TODO: doesn't support disabling multiple rules by name for a given line. + new_fixes.push(disable_for_this_line(rule_name, error_offset, rope, source_text)); + new_fixes.push(disable_for_this_section(rule_name, section_offset, rope, source_text)); + } + + if new_fixes.is_empty() { + PossibleFixesWithPosition::None + } else if new_fixes.len() == 1 { + PossibleFixesWithPosition::Single(new_fixes.remove(0)) + } else { + PossibleFixesWithPosition::Multiple(new_fixes) + } +} + +fn disable_for_this_line<'a>( + rule_name: &str, + error_offset: u32, + rope: &Rope, + source_text: &str, +) -> FixWithPosition<'a> { + let mut start_position = offset_to_position(rope, error_offset, source_text); + 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. + let end_position = start_position.clone(); + FixWithPosition { + content: Cow::Owned(format!("// oxlint-disable-next-line {rule_name}\n")), + span: SpanPositionMessage::new(start_position, end_position) + .with_message(Some(Cow::Owned(format!("Disable {rule_name} for this line")))), + } +} + +fn disable_for_this_section<'a>( + rule_name: &str, + section_offset: u32, + rope: &Rope, + source_text: &str, +) -> FixWithPosition<'a> { + let comment = format!("// oxlint-disable {rule_name}\n"); + + let (content, offset) = if section_offset == 0 { + // JS files - insert at the beginning + (Cow::Owned(comment), section_offset) + } else { + // Framework files - check for line breaks at section_offset + let bytes = source_text.as_bytes(); + let current = bytes.get(section_offset as usize); + let next = bytes.get((section_offset + 1) as usize); + + match (current, next) { + (Some(b'\n'), _) => { + // LF at offset, insert after it + (Cow::Owned(comment), section_offset + 1) + } + (Some(b'\r'), Some(b'\n')) => { + // CRLF at offset, insert after both + (Cow::Owned(comment), section_offset + 2) + } + _ => { + // Not at line start, prepend newline + (Cow::Owned("\n".to_owned() + &comment), section_offset) + } + } + }; + + let position = offset_to_position(rope, offset, source_text); + + FixWithPosition { + content, + span: SpanPositionMessage::new(position.clone(), position) + .with_message(Some(Cow::Owned(format!("Disable {rule_name} for this file")))), + } +} + +#[cfg(test)] +mod test { + use oxc_data_structures::rope::Rope; + + use super::offset_to_position; + + #[test] + fn single_line() { + let source = "foo.bar!;"; + assert_position(source, 0, (0, 0)); + assert_position(source, 4, (0, 4)); + assert_position(source, 9, (0, 9)); + } + + #[test] + fn multi_line() { + let source = "console.log(\n foo.bar!\n);"; + assert_position(source, 0, (0, 0)); + assert_position(source, 12, (0, 12)); + assert_position(source, 13, (1, 0)); + assert_position(source, 23, (1, 10)); + assert_position(source, 24, (2, 0)); + assert_position(source, 26, (2, 2)); + } + + #[test] + fn multi_byte() { + let source = "let foo = \n '👍';"; + assert_position(source, 10, (0, 10)); + assert_position(source, 11, (1, 0)); + assert_position(source, 14, (1, 3)); + assert_position(source, 18, (1, 5)); + assert_position(source, 19, (1, 6)); + } + + #[test] + #[should_panic(expected = "out of bounds")] + fn out_of_bounds() { + offset_to_position(&Rope::from_str("foo"), 100, "foo"); + } + + #[test] + fn disable_for_section_js_file() { + let source = "console.log('hello');"; + let rope = Rope::from_str(source); + let fix = super::disable_for_this_section("no-console", 0, &rope, source); + + assert_eq!(fix.content, "// oxlint-disable no-console\n"); + assert_eq!(fix.span.start.line, 0); + assert_eq!(fix.span.start.character, 0); + } + + #[test] + fn disable_for_section_after_lf() { + let source = "