From 621808eb3134e6d95c2dcfb26286e19c39ce6b2e Mon Sep 17 00:00:00 2001 From: Boshen Date: Sun, 16 Aug 2026 19:06:18 +0800 Subject: [PATCH] perf(diagnostics): measure graphemes lazily (#25723) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lazily measure non-ASCII graphemes instead of allocating an eager boundary table. Criterion single_unicode_tab: 1.1575 µs → 596.36 ns (-48.6%). ASCII cases remain neutral or better. Kibana output is unchanged; end-to-end performance is neutral without type-aware linting. AI-assisted. --- .../src/handlers/graphical/line.rs | 56 +++++++------------ 1 file changed, 21 insertions(+), 35 deletions(-) diff --git a/crates/oxc_diagnostics/src/handlers/graphical/line.rs b/crates/oxc_diagnostics/src/handlers/graphical/line.rs index c778aa6583..542fc28f8e 100644 --- a/crates/oxc_diagnostics/src/handlers/graphical/line.rs +++ b/crates/oxc_diagnostics/src/handlers/graphical/line.rs @@ -10,10 +10,13 @@ //! helpers translate byte offsets into terminal columns, accounting for tabs, //! ANSI escapes, and wide/combining Unicode graphemes. -use std::str::{CharIndices, from_utf8}; +use std::{ + iter::Peekable, + str::{CharIndices, from_utf8}, +}; use smallvec::SmallVec; -use unicode_segmentation::UnicodeSegmentation; +use unicode_segmentation::{GraphemeIndices, UnicodeSegmentation}; use unicode_width::UnicodeWidthStr; use super::{handler::GraphicalReportHandler, span::FancySpan}; @@ -87,13 +90,12 @@ impl Line<'_> { /// Iterator over the visual (terminal-column) width of each `char` in a line. /// /// ASCII text takes a fast path where every printable char is width 1. For -/// non-ASCII text we pre-compute grapheme boundaries and only charge the -/// grapheme's width to its first `char`, so combining marks contribute 0. +/// non-ASCII text we lazily visit graphemes and only charge the grapheme's +/// width to its first `char`, so combining marks contribute 0. /// ANSI escape sequences (`\x1b … m`) are consumed at zero width. struct CharWidthIterator<'a> { chars: CharIndices<'a>, - grapheme_boundaries: Option>, // (byte_pos, width) - None for ASCII - current_grapheme_idx: usize, + graphemes: Option>>, column: usize, escaped: bool, } @@ -111,17 +113,10 @@ impl Iterator for CharWidthIterator<'_> { 0 } (false, _) => { - if let Some(ref boundaries) = self.grapheme_boundaries { - // Unicode path: check if we're at a grapheme boundary - if self.current_grapheme_idx < boundaries.len() - && boundaries[self.current_grapheme_idx].0 == byte_pos - { - let width = boundaries[self.current_grapheme_idx].1; - self.current_grapheme_idx += 1; - width - } else { - 0 // Not at a grapheme boundary - } + if let Some(graphemes) = &mut self.graphemes { + graphemes + .next_if(|(pos, _)| *pos == byte_pos) + .map_or(0, |(_, grapheme)| grapheme.width()) } else { // ASCII path: all non-control chars are width 1 1 @@ -142,25 +137,9 @@ impl Iterator for CharWidthIterator<'_> { impl GraphicalReportHandler { /// Returns an iterator over the visual width of each character in a line. pub(super) fn line_visual_char_width(text: &str) -> impl Iterator + '_ + use<'_> { - // Only compute grapheme boundaries for non-ASCII text - let grapheme_boundaries = if text.is_ascii() { - None - } else { - // Collect grapheme boundaries with their widths - Some( - text.grapheme_indices(true) - .map(|(pos, grapheme)| (pos, grapheme.width())) - .collect(), - ) - }; + let graphemes = (!text.is_ascii()).then(|| text.grapheme_indices(true).peekable()); - CharWidthIterator { - chars: text.char_indices(), - grapheme_boundaries, - current_grapheme_idx: 0, - column: 0, - escaped: false, - } + CharWidthIterator { chars: text.char_indices(), graphemes, column: 0, escaped: false } } /// Returns the visual column position of a byte offset on a specific line. @@ -259,6 +238,13 @@ mod tests { type ExpectedLine<'a> = (usize, usize, usize, &'a str); + #[test] + fn visual_widths_preserve_grapheme_geometry() { + let widths = + GraphicalReportHandler::line_visual_char_width("e\u{301}火").collect::>(); + assert_eq!(widths, [1, 0, 2]); + } + #[test] fn get_lines_preserves_line_geometry() { const BASE: usize = 10;