diff --git a/crates/oxc_diagnostics/src/named_source.rs b/crates/oxc_diagnostics/src/named_source.rs index e46e59d0fd..05b85b88cd 100644 --- a/crates/oxc_diagnostics/src/named_source.rs +++ b/crates/oxc_diagnostics/src/named_source.rs @@ -1,18 +1,33 @@ -use std::{fmt, sync::OnceLock}; +use std::{ + fmt, + sync::{Mutex, OnceLock}, +}; use crate::{SourceCode, source_impls}; /// Utility struct for when you have a regular [`SourceCode`] type that doesn't /// implement `name`. For example [`String`]. Or if you want to override the /// `name` returned by the `SourceCode`. -#[derive(Clone)] pub struct NamedSource { source: S, name: String, - /// Line starts of `source`, computed on first use. Reporters resolve every - /// diagnostic's line and column through this, so a file's many diagnostics - /// share one scan instead of each rescanning the file. + /// Complete index, built only when explicitly requested. line_starts: OnceLock>>, + /// Position queries retain only the line starts scanned so far. + line_index: Mutex, +} + +impl Clone for NamedSource { + fn clone(&self) -> Self { + Self { + source: self.source.clone(), + name: self.name.clone(), + line_starts: self.line_starts.clone(), + line_index: Mutex::new( + self.line_index.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone(), + ), + } + } } impl fmt::Debug for NamedSource { @@ -29,7 +44,12 @@ impl NamedSource { /// a name. #[must_use] pub fn new(name: impl AsRef, source: S) -> Self { - Self { source, name: name.as_ref().to_string(), line_starts: OnceLock::new() } + Self { + source, + name: name.as_ref().to_string(), + line_starts: OnceLock::new(), + line_index: Mutex::default(), + } } } @@ -45,4 +65,63 @@ impl SourceCode for NamedSource { fn line_starts(&self) -> Option<&[u32]> { self.line_starts.get_or_init(|| source_impls::line_starts(self.source.data())).as_deref() } + + fn line_column(&self, offset: usize) -> Option<(usize, usize)> { + let input = self.source.data(); + if let Some(line_starts) = self.line_starts.get().and_then(Option::as_deref) { + return source_impls::line_column(input, Some(line_starts), offset); + } + if u32::try_from(input.len()).is_err() { + return source_impls::line_column(input, None, offset); + } + self.line_index + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .line_column(input, offset) + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use oxc_span::Span; + + use crate::{OxcDiagnostic, reporter::Info}; + + use super::*; + + #[test] + fn early_diagnostic_does_not_initialize_complete_index() { + let source = Arc::new(NamedSource::new("file.js", "line\n".repeat(200_000))); + let diagnostic = OxcDiagnostic::error("test") + .with_label(Span::new(0, 1)) + .with_source_code(Arc::clone(&source)); + let info = Info::new(&diagnostic); + assert_eq!((info.start.line, info.start.column), (1, 1)); + assert_eq!((info.end.line, info.end.column), (1, 2)); + assert!(source.line_starts.get().is_none()); + } + + #[test] + fn cloned_and_shared_indexes_match_direct_scan() { + let text = "first\r\nsecond\n\u{00e9}\r\u{1f980}\n"; + let source = NamedSource::new("file.js", text); + source.line_column(8).unwrap(); + let cloned = Arc::new(source.clone()); + std::thread::scope(|scope| { + for _ in 0..4 { + scope.spawn(|| { + for offset in (0..=text.len() + 1).rev() { + assert_eq!(cloned.line_column(offset), text.line_column(offset)); + } + }); + } + }); + assert!(source.line_starts.get().is_none()); + cloned.line_starts().unwrap(); + for offset in 0..=text.len() + 1 { + assert_eq!(source.line_column(offset), cloned.line_column(offset)); + } + } } diff --git a/crates/oxc_diagnostics/src/protocol.rs b/crates/oxc_diagnostics/src/protocol.rs index ebbb1844bc..2827419e06 100644 --- a/crates/oxc_diagnostics/src/protocol.rs +++ b/crates/oxc_diagnostics/src/protocol.rs @@ -84,4 +84,12 @@ pub trait SourceCode: Send + Sync { fn line_starts(&self) -> Option<&[u32]> { None } + + /// Returns the one-based line and byte column at `offset`. + /// + /// Returns `None` if the offset is past the end of the source. An offset + /// inside a CRLF pair refers to the position of its CR. + fn line_column(&self, offset: usize) -> Option<(usize, usize)> { + crate::source_impls::line_column(self.data(), self.line_starts(), offset) + } } diff --git a/crates/oxc_diagnostics/src/reporter.rs b/crates/oxc_diagnostics/src/reporter.rs index bf8722da06..dbe0ff2108 100644 --- a/crates/oxc_diagnostics/src/reporter.rs +++ b/crates/oxc_diagnostics/src/reporter.rs @@ -140,36 +140,8 @@ pub struct InfoPosition { pub column: usize, } -fn line_column(source: &dyn SourceCode, mut offset: usize) -> Option { - let data = source.data(); - if offset > data.len() { - return None; - } - if offset > 0 && offset < data.len() && data[offset - 1] == b'\r' && data[offset] == b'\n' { - offset -= 1; - } - - if let Some(line_starts) = source.line_starts() { - // `line_starts[0] == 0`, so at least one line starts at or before `offset`. - let line = line_starts.partition_point(|&start| start as usize <= offset); - let line_start = line_starts[line - 1] as usize; - return Some(InfoPosition { line, column: offset - line_start + 1 }); - } - - let mut line = 1; - let mut line_start = 0; - for index in memchr::memchr2_iter(b'\r', b'\n', &data[..offset]) { - if data[index] == b'\n' && index > 0 && data[index - 1] == b'\r' { - continue; - } - line += 1; - line_start = if data[index] == b'\r' && index + 1 < offset && data[index + 1] == b'\n' { - index + 2 - } else { - index + 1 - }; - } - Some(InfoPosition { line, column: offset - line_start + 1 }) +fn line_column(source: &dyn SourceCode, offset: usize) -> Option { + source.line_column(offset).map(|(line, column)| InfoPosition { line, column }) } impl Info { @@ -212,7 +184,9 @@ impl Info { #[cfg(test)] mod test { - use crate::{Error, NamedSource, OxcDiagnostic}; + use std::sync::Arc; + + use crate::{Error, NamedSource, OxcDiagnostic, SourceCode}; use super::{Info, line_column}; @@ -228,7 +202,8 @@ mod test { text.push(ALPHABET[code % 3] as char); code /= 3; } - let indexed = NamedSource::new("file.js", text.clone()); + let indexed = Arc::new(NamedSource::new("file.js", text.clone())); + let cloned = indexed.as_ref().clone(); for offset in 0..=text.len() + 1 { assert_eq!( line_column(&indexed, offset), @@ -236,6 +211,14 @@ mod test { "{text:?} at {offset}" ); } + for offset in (0..=text.len() + 1).rev() { + assert_eq!(line_column(&indexed, offset), line_column(&text, offset)); + assert_eq!(line_column(&cloned, offset), line_column(&text, offset)); + } + indexed.line_starts().unwrap(); + for offset in 0..=text.len() + 1 { + assert_eq!(line_column(&indexed, offset), line_column(&text, offset)); + } } } } diff --git a/crates/oxc_diagnostics/src/source_impls.rs b/crates/oxc_diagnostics/src/source_impls.rs index c957f7355d..b4b6c721a3 100644 --- a/crates/oxc_diagnostics/src/source_impls.rs +++ b/crates/oxc_diagnostics/src/source_impls.rs @@ -803,6 +803,74 @@ impl SourceCode for Arc { fn line_starts(&self) -> Option<&[u32]> { self.as_ref().line_starts() } + + fn line_column(&self, offset: usize) -> Option<(usize, usize)> { + self.as_ref().line_column(offset) + } +} + +/// Resolve a position using a complete index or a direct prefix scan. +pub fn line_column( + input: &[u8], + line_starts: Option<&[u32]>, + offset: usize, +) -> Option<(usize, usize)> { + if offset > input.len() { + return None; + } + let offset = normalize_offset(input, offset); + if let Some(line_starts) = line_starts { + let line = line_starts.partition_point(|&start| start as usize <= offset); + return Some((line, offset - line_starts[line - 1] as usize + 1)); + } + let mut line = 1; + let mut line_start = 0; + for index in memchr::memchr2_iter(b'\r', b'\n', &input[..offset]) { + if input[index] == b'\n' && index > 0 && input[index - 1] == b'\r' { + continue; + } + line += 1; + line_start = if input[index] == b'\r' && index + 1 < offset && input[index + 1] == b'\n' { + index + 2 + } else { + index + 1 + }; + } + Some((line, offset - line_start + 1)) +} + +fn normalize_offset(input: &[u8], offset: usize) -> usize { + if offset > 0 && offset < input.len() && input[offset - 1] == b'\r' && input[offset] == b'\n' { + offset - 1 + } else { + offset + } +} + +/// Shared reporters extend this index only through their requested positions. +#[derive(Clone, Default)] +pub struct SourceLineIndex { + line_starts: Vec, + frontier: usize, +} + +impl SourceLineIndex { + pub fn line_column(&mut self, input: &[u8], offset: usize) -> Option<(usize, usize)> { + if offset > input.len() { + return None; + } + let offset = normalize_offset(input, offset); + if offset > self.frontier { + for line_break in LineBreaks::new(&input[self.frontier..offset]) { + self.line_starts + .push(u32::try_from(self.frontier + line_break.next_line_start()).ok()?); + } + self.frontier = offset; + } + let line = self.line_starts.partition_point(|&start| start as usize <= offset); + let line_start = line.checked_sub(1).map_or(0, |index| self.line_starts[index] as usize); + Some((line + 1, offset - line_start + 1)) + } } /// Byte offsets at which each line of `input` starts, with CRLF counted as one line break. @@ -820,6 +888,30 @@ pub fn line_starts(input: &[u8]) -> Option> { mod tests { use super::*; + #[test] + fn source_line_index_only_scans_requested_prefixes() { + let input = b"first\r\nsecond\nthird\rfourth"; + let mut index = SourceLineIndex::default(); + assert_eq!(index.line_column(input, 1), Some((1, 2))); + assert_eq!(index.frontier, 1); + assert_eq!(index.line_starts, [] as [u32; 0]); + + // An offset on the LF keeps the whole CRLF beyond the frontier. + assert_eq!(index.line_column(input, 6), Some((1, 6))); + assert_eq!(index.frontier, 5); + assert_eq!(index.line_starts, [] as [u32; 0]); + assert_eq!(index.line_column(input, 8), Some((2, 2))); + assert_eq!(index.frontier, 8); + assert_eq!(index.line_starts, [7]); + + assert_eq!(index.line_column(input, 0), Some((1, 1))); + assert_eq!(index.frontier, 8); + assert_eq!(index.line_column(input, input.len() + 1), None); + assert_eq!(index.frontier, 8); + assert_eq!(index.line_column(input, input.len()), Some((4, 7))); + assert_eq!(index.line_starts, [7, 14, 20]); + } + #[test] fn zero_context_prefix_matches_generic_path() { for input in [