perf(diagnostics): build reporter line index incrementally

This commit is contained in:
Dunqing committed 2026-11-02 12:28:23 +08:00
1 parent 041085af90
commit 5d12c9c1d0
4 files changed
+200 -38

No files matched your search

+85 -6
View File
@@ -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<S: SourceCode> {
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<Option<Box<[u32]>>>,
/// Position queries retain only the line starts scanned so far.
line_index: Mutex<source_impls::SourceLineIndex>,
}
impl<S: SourceCode + Clone> Clone for NamedSource<S> {
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<S: SourceCode> fmt::Debug for NamedSource<S> {
@@ -29,7 +44,12 @@ impl<S: SourceCode> NamedSource<S> {
/// a name.
#[must_use]
pub fn new(name: impl AsRef<str>, 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<S: SourceCode> SourceCode for NamedSource<S> {
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));
}
}
}
+8
View File
@@ -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)
}
}
+15 -32
View File
@@ -140,36 +140,8 @@ pub struct InfoPosition {
pub column: usize,
}
fn line_column(source: &dyn SourceCode, mut offset: usize) -> Option<InfoPosition> {
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<InfoPosition> {
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));
}
}
}
}
@@ -803,6 +803,74 @@ impl<T: ?Sized + SourceCode> SourceCode for Arc<T> {
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<u32>,
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<Box<[u32]>> {
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 [