Skip to main content

text_document/
highlight.rs

1//! Syntax highlighting support.
2//!
3//! Provides a [`SyntaxHighlighter`] trait inspired by Qt's `QSyntaxHighlighter`.
4//! Implementors produce shadow formatting that is merged into
5//! [`FragmentContent`] at layout time but never touches the stored
6//! `format_runs` / `block_images` tables — export, cursor, undo, and
7//! search remain unaffected.
8
9use std::any::Any;
10use std::collections::HashMap;
11use std::sync::Arc;
12
13use frontend::commands::block_commands;
14
15use crate::flow::FragmentContent;
16use crate::inner::TextDocumentInner;
17use crate::{CharVerticalAlignment, Color, TextFormat, UnderlineStyle};
18
19// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
20// Public types
21// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
22
23/// Formatting applied by a syntax highlighter to a text range.
24///
25/// All fields are `Option`: `None` means "don't override the real format."
26/// Only non-`None` fields take precedence over the corresponding
27/// [`TextFormat`] field for display purposes.
28#[derive(Debug, Clone, Default, PartialEq, Eq)]
29pub struct HighlightFormat {
30    pub foreground_color: Option<Color>,
31    pub background_color: Option<Color>,
32    pub underline_color: Option<Color>,
33    pub font_family: Option<String>,
34    pub font_point_size: Option<u32>,
35    pub font_weight: Option<u32>,
36    pub font_bold: Option<bool>,
37    pub font_italic: Option<bool>,
38    pub font_underline: Option<bool>,
39    pub font_overline: Option<bool>,
40    pub font_strikeout: Option<bool>,
41    pub letter_spacing: Option<i32>,
42    pub word_spacing: Option<i32>,
43    pub underline_style: Option<UnderlineStyle>,
44    pub vertical_alignment: Option<CharVerticalAlignment>,
45    pub tooltip: Option<String>,
46}
47
48/// A single highlight span within a block.
49///
50/// `start` and `length` are block-relative **character** offsets.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct HighlightSpan {
53    pub start: usize,
54    pub length: usize,
55    pub format: HighlightFormat,
56}
57
58/// Context passed to [`SyntaxHighlighter::highlight_block`].
59///
60/// Provides methods to set highlight formatting and manage per-block state.
61pub struct HighlightContext {
62    spans: Vec<HighlightSpan>,
63    previous_state: i64,
64    current_state: i64,
65    block_id: usize,
66    user_data: Option<Box<dyn Any + Send + Sync>>,
67}
68
69impl HighlightContext {
70    /// Create a new context for highlighting a block.
71    pub fn new(
72        block_id: usize,
73        previous_state: i64,
74        user_data: Option<Box<dyn Any + Send + Sync>>,
75    ) -> Self {
76        Self {
77            spans: Vec::new(),
78            previous_state,
79            current_state: -1,
80            block_id,
81            user_data,
82        }
83    }
84
85    /// Apply a highlight format to a character range within the current block.
86    ///
87    /// Zero-length spans are silently ignored.
88    pub fn set_format(&mut self, start: usize, length: usize, format: HighlightFormat) {
89        if length == 0 {
90            return;
91        }
92        self.spans.push(HighlightSpan {
93            start,
94            length,
95            format,
96        });
97    }
98
99    /// Get the block state of the previous block (−1 if no state was set).
100    pub fn previous_block_state(&self) -> i64 {
101        self.previous_state
102    }
103
104    /// Set the block state for the current block.
105    ///
106    /// If the new state differs from the previously stored value, the next
107    /// block will be re-highlighted automatically (cascade).
108    pub fn set_current_block_state(&mut self, state: i64) {
109        self.current_state = state;
110    }
111
112    /// Get the current block state (defaults to −1).
113    pub fn current_block_state(&self) -> i64 {
114        self.current_state
115    }
116
117    /// Get the block ID.
118    pub fn block_id(&self) -> usize {
119        self.block_id
120    }
121
122    /// Set per-block user data (replaces any existing data).
123    pub fn set_user_data(&mut self, data: Box<dyn Any + Send + Sync>) {
124        self.user_data = Some(data);
125    }
126
127    /// Get a reference to the per-block user data.
128    pub fn user_data(&self) -> Option<&(dyn Any + Send + Sync)> {
129        self.user_data.as_deref()
130    }
131
132    /// Get a mutable reference to the per-block user data.
133    pub fn user_data_mut(&mut self) -> Option<&mut (dyn Any + Send + Sync)> {
134        self.user_data.as_deref_mut()
135    }
136
137    /// Consume the context and return the accumulated spans, final state,
138    /// and user data.
139    pub fn into_parts(self) -> (Vec<HighlightSpan>, i64, Option<Box<dyn Any + Send + Sync>>) {
140        (self.spans, self.current_state, self.user_data)
141    }
142}
143
144/// A user-implemented syntax highlighter that applies visual-only formatting.
145///
146/// Inspired by Qt's `QSyntaxHighlighter`. Implement this trait and attach it
147/// to a document via [`TextDocument::set_syntax_highlighter`](crate::TextDocument::set_syntax_highlighter).
148///
149/// The highlighter is called once per block when the document content changes.
150/// Use [`HighlightContext::set_format`] to apply highlight spans. Use
151/// [`HighlightContext::set_current_block_state`] and
152/// [`HighlightContext::previous_block_state`] for multi-block constructs
153/// (e.g., multiline comments).
154pub trait SyntaxHighlighter: Send + Sync {
155    /// Called for each block that needs re-highlighting.
156    fn highlight_block(&self, text: &str, ctx: &mut HighlightContext);
157}
158
159/// Identifies one registered highlight session (see [`crate::TextDocument::add_syntax_session`]
160/// / [`crate::TextDocument::add_range_session`]).
161///
162/// A document can carry several highlight layers at once — a syntax highlighter, a
163/// spell-checker, and one find session *per view*. Each is a session with its own id, so a
164/// per-view [`HighlightMask`] can name exactly the ones a given pane should render.
165#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
166pub struct SessionId(pub u64);
167
168/// One highlight range carried by a *range session*, in **absolute document char offsets** —
169/// the same coordinate space [`crate::FindMatch`] and replace report in.
170///
171/// A range session is the shape used for search highlighting and (eventually) an
172/// externally-driven spell-checker: the host computes the ranges and hands the whole set over
173/// with [`crate::TextDocument::set_session_ranges`], rather than implementing a per-block
174/// callback. The document slices these absolute ranges to per-block spans at snapshot time
175/// (a block's absolute char start is its `document_position`).
176#[derive(Debug, Clone, PartialEq, Eq)]
177pub struct RangeHighlight {
178    /// Absolute char offset into the document text.
179    pub start: usize,
180    pub length: usize,
181    pub format: HighlightFormat,
182}
183
184/// Which highlight sessions a particular **view** renders.
185///
186/// Two panes over one shared document can carry different find queries, so "which highlights
187/// to show" is a property of the *view*, not the document. A snapshot is built under a mask;
188/// only the sessions the mask admits contribute spans, and the effective
189/// `HighlighterKind` is the join over just those.
190///
191/// The default ([`HighlightMask::all`]) shows every session — the behaviour of the old
192/// `snapshot_flow()`. [`HighlightMask::none`] shows none — the old
193/// `snapshot_flow_without_highlights()`, and it must stay exactly as cheap, since every
194/// read-only preview pane uses it.
195#[derive(Debug, Clone, Default, PartialEq, Eq)]
196pub struct HighlightMask {
197    /// `None` = admit every session (the default). `Some(set)` = admit only these ids.
198    included: Option<Vec<SessionId>>,
199}
200
201impl HighlightMask {
202    /// The all-admitting mask as a `const`, so a snapshot builder can reference "show
203    /// everything" with a `'static` lifetime — no temporary to outlive.
204    pub(crate) const ALL: HighlightMask = HighlightMask { included: None };
205
206    /// Show every session attached to the document. The default, and the shape of a plain
207    /// `snapshot_flow()`.
208    pub fn all() -> Self {
209        Self { included: None }
210    }
211
212    /// Show no highlights at all — as cheap as the old `show_highlights = false`.
213    pub fn none() -> Self {
214        Self {
215            included: Some(Vec::new()),
216        }
217    }
218
219    /// Show only the named sessions (e.g. the shared syntax + spell sessions plus *this*
220    /// view's own find session).
221    pub fn only(ids: impl IntoIterator<Item = SessionId>) -> Self {
222        Self {
223            included: Some(ids.into_iter().collect()),
224        }
225    }
226
227    /// Add a session to a mask that already names some (a no-op on [`HighlightMask::all`],
228    /// which already admits everything).
229    pub fn with(mut self, id: SessionId) -> Self {
230        if let Some(ids) = &mut self.included
231            && !ids.contains(&id)
232        {
233            ids.push(id);
234        }
235        self
236    }
237
238    /// Whether this mask admits `id`.
239    pub(crate) fn admits(&self, id: SessionId) -> bool {
240        match &self.included {
241            None => true,
242            Some(ids) => ids.contains(&id),
243        }
244    }
245
246    /// Whether this mask admits nothing — the fast path an empty/no-op preview takes, which
247    /// must be exactly as cheap as the old boolean `false`.
248    pub(crate) fn is_empty(&self) -> bool {
249        matches!(&self.included, Some(ids) if ids.is_empty())
250    }
251}
252
253/// What a snapshot renders, resolved **once at the root** and threaded down unchanged: the
254/// effective [`HighlighterKind`](enum@HighlighterKind) (the join over the view's admitted
255/// sessions) and the mask that selected them.
256///
257/// This replaces the plain `effective_kind: HighlighterKind` the block/frame builders used to
258/// take — carrying the mask alongside so the leaf that resolves a block's spans knows which
259/// sessions this view shows, without re-deriving the kind per block.
260#[derive(Clone, Copy)]
261pub(crate) struct SnapshotHighlights<'a> {
262    pub kind: HighlighterKind,
263    pub mask: &'a HighlightMask,
264    /// Skip the paint-only overlay (`paint_highlights`) entirely: fragments are
265    /// still split for metric sessions, but no `extract_paint_spans` runs. For
266    /// consumers that read only the fragments/geometry and discard the visual
267    /// overlay — the accessibility tree above all — this drops the whole
268    /// paint-span computation, which is O(spans) per block (superlinear when a
269    /// block carries thousands of ranges, e.g. a spell-checked Lorem scene).
270    /// The produced `fragments` are byte-identical to a normal snapshot; only
271    /// `paint_highlights` differs (always empty here).
272    pub suppress_paint: bool,
273}
274
275// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
276// Internal storage
277// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
278
279/// Per-block highlight state.
280pub(crate) struct BlockHighlightData {
281    pub spans: Vec<HighlightSpan>,
282    pub state: i64,
283    pub user_data: Option<Box<dyn Any + Send + Sync>>,
284}
285
286/// A **syntax session**: a callback highlighter and its per-block cascade cache. This is
287/// exactly the old single-highlighter storage — one `SyntaxHighlighter` invoked once per
288/// block, with its own `previous_state`/`current_state` timeline and per-block user data.
289///
290/// Each syntax session owns its cascade **independently**. A multiline-comment state from one
291/// highlighter must never leak into another's `previous_block_state`, so the state is threaded
292/// per session, never shared.
293pub(crate) struct SyntaxSession {
294    pub highlighter: Arc<dyn SyntaxHighlighter>,
295    pub blocks: HashMap<usize, BlockHighlightData>,
296}
297
298/// A **range session**: absolute-offset ranges, set wholesale by the host and sliced to
299/// per-block spans on demand. No callback, no cascade — used for find and spell.
300///
301/// The ranges carry a **per-block index** and a **cached kind**, both built once by
302/// [`set_ranges`](HighlightRegistry::set_ranges) from the document's block layout at push time.
303/// They replace two per-query full scans of the whole range vector that made a snapshot
304/// O(blocks × ranges): a spell-check of a Lorem-Ipsum-dense document flags one range per word
305/// (tens of thousands of them), and *every* block used to walk *all* of them.
306pub(crate) struct RangeSession {
307    pub ranges: Vec<RangeHighlight>,
308    /// `block_id → indices into `ranges` that overlap that block`, at the block layout of the
309    /// last push. A block **absent** from the map has no ranges for this session.
310    ///
311    /// Freshness: the ranges' absolute offsets and this index are a matched snapshot of one
312    /// push. If the document is edited *without* a following push, both go stale together — the
313    /// same window the un-indexed code already had (it, too, clipped last-push absolute ranges
314    /// against fresh geometry). The one new shape is **missing vs. stale**: a block *created*
315    /// since the last push has no key here, so it shows nothing (rather than a stale span) until
316    /// the next push rebuilds the index. For the spell producer a structural edit re-tokenises
317    /// and re-pushes on the next frame, closing the window; a brand-new empty block has nothing
318    /// to flag anyway.
319    block_index: HashMap<usize, Vec<u32>>,
320    /// The session's [`HighlighterKind`], computed in the same pass as `block_index` so
321    /// [`effective_kind`](HighlightRegistry::effective_kind) is O(1) per session instead of a
322    /// full range scan on every snapshot root.
323    kind: HighlighterKind,
324}
325
326/// The two session shapes.
327pub(crate) enum SessionBody {
328    Syntax(SyntaxSession),
329    Range(RangeSession),
330}
331
332/// One registered session with its stable id.
333pub(crate) struct Session {
334    pub id: SessionId,
335    pub body: SessionBody,
336}
337
338/// Every highlight session on the document, in **registration order** — which is the merge
339/// order: when two sessions format the same character, the later-registered one wins, field
340/// by field (see [`merge_overlapping_highlights`]). Replaces the old single
341/// `Option<HighlightData>` slot.
342#[derive(Default)]
343pub(crate) struct HighlightRegistry {
344    pub sessions: Vec<Session>,
345    next_id: u64,
346    /// The session owned by the classic single-highlighter `set_syntax_highlighter` shim, if
347    /// one is installed. Kept apart so the shim replaces **only its own** session and never a
348    /// spell-checker or find layer another caller added via `add_syntax_session`.
349    shim: Option<SessionId>,
350}
351
352impl HighlightRegistry {
353    /// Mint the next session id.
354    fn alloc_id(&mut self) -> SessionId {
355        let id = SessionId(self.next_id);
356        self.next_id += 1;
357        id
358    }
359
360    /// Register a syntax session (empty cache; the caller rehighlights).
361    pub(crate) fn add_syntax(&mut self, highlighter: Arc<dyn SyntaxHighlighter>) -> SessionId {
362        let id = self.alloc_id();
363        self.sessions.push(Session {
364            id,
365            body: SessionBody::Syntax(SyntaxSession {
366                highlighter,
367                blocks: HashMap::new(),
368            }),
369        });
370        id
371    }
372
373    /// Register an empty range session.
374    pub(crate) fn add_range(&mut self) -> SessionId {
375        let id = self.alloc_id();
376        self.sessions.push(Session {
377            id,
378            body: SessionBody::Range(RangeSession {
379                ranges: Vec::new(),
380                block_index: HashMap::new(),
381                kind: HighlighterKind::None,
382            }),
383        });
384        id
385    }
386
387    /// Replace a range session's ranges, building its per-block index and cached kind from the
388    /// document's `block_positions` (each `(block_id, absolute_char_start)`, sorted by start).
389    /// Returns `false` if `id` is not a range session (or does not exist) — a caller handing
390    /// ranges to a syntax session is a bug, not a silent no-op to swallow.
391    pub(crate) fn set_ranges(
392        &mut self,
393        id: SessionId,
394        ranges: Vec<RangeHighlight>,
395        block_positions: &[(u64, usize)],
396    ) -> bool {
397        for s in &mut self.sessions {
398            if s.id == id {
399                if let SessionBody::Range(r) = &mut s.body {
400                    r.kind = compute_range_kind(&ranges);
401                    r.block_index = build_block_index(&ranges, block_positions);
402                    r.ranges = ranges;
403                    return true;
404                }
405                return false;
406            }
407        }
408        false
409    }
410
411    /// Retire a session. Returns whether it existed.
412    pub(crate) fn remove(&mut self, id: SessionId) -> bool {
413        let before = self.sessions.len();
414        self.sessions.retain(|s| s.id != id);
415        self.sessions.len() != before
416    }
417
418    /// Install / replace / clear the classic single-highlighter shim
419    /// (`set_syntax_highlighter`). Replaces **only** the shim's own session — a spell-checker
420    /// or any other layer registered independently via [`add_syntax`](Self::add_syntax) is left
421    /// untouched. `None` clears the shim.
422    pub(crate) fn set_shim(&mut self, highlighter: Option<Arc<dyn SyntaxHighlighter>>) {
423        if let Some(id) = self.shim.take() {
424            self.remove(id);
425        }
426        if let Some(hl) = highlighter {
427            self.shim = Some(self.add_syntax(hl));
428        }
429    }
430
431    /// Whether any session is attached.
432    pub(crate) fn is_empty(&self) -> bool {
433        self.sessions.is_empty()
434    }
435}
436
437/// Classification of the active highlighter's output.
438///
439/// Drives whether highlights are merged into the shaping input
440/// (`fragments`) or kept as a separate post-shape recolor overlay.
441#[derive(Debug, Clone, Copy, PartialEq, Eq)]
442pub(crate) enum HighlighterKind {
443    /// No highlighter attached.
444    None,
445    /// Every span touches only paint attributes (colors, underline
446    /// style, underline/overline/strikeout, tooltip). Glyph metrics are
447    /// unchanged, so the layout engine can recolor without reshaping —
448    /// `fragments` stay base and the spans ride as a paint overlay.
449    PaintOnly,
450    /// At least one span touches a metric-affecting field. Highlights
451    /// are merged into `fragments` (reshape required on change).
452    Metric,
453}
454
455/// Returns `true` if this format sets a metric-affecting field, i.e. one that changes glyph
456/// advances or line height: font family / size / weight / bold / italic, letter / word
457/// spacing, or vertical alignment (sub/superscript). The color and underline-decoration fields
458/// are paint-only and never trigger `true`.
459pub(crate) fn format_touches_metrics(f: &HighlightFormat) -> bool {
460    f.font_family.is_some()
461        || f.font_point_size.is_some()
462        || f.font_weight.is_some()
463        || f.font_bold.is_some()
464        || f.font_italic.is_some()
465        || f.letter_spacing.is_some()
466        || f.word_spacing.is_some()
467        || f.vertical_alignment.is_some()
468}
469
470/// Returns `true` if any span sets a metric-affecting field. See [`format_touches_metrics`].
471pub(crate) fn spans_touch_metrics(spans: &[HighlightSpan]) -> bool {
472    spans.iter().any(|s| format_touches_metrics(&s.format))
473}
474
475impl HighlighterKind {
476    /// None < PaintOnly < Metric. The join over a view's admitted sessions is the max: one
477    /// metric-affecting session forces the reshape path for the whole snapshot.
478    fn rank(self) -> u8 {
479        match self {
480            HighlighterKind::None => 0,
481            HighlighterKind::PaintOnly => 1,
482            HighlighterKind::Metric => 2,
483        }
484    }
485
486    fn join(self, other: HighlighterKind) -> HighlighterKind {
487        if other.rank() > self.rank() {
488            other
489        } else {
490            self
491        }
492    }
493}
494
495/// The kind of one syntax session, from its cached spans.
496fn syntax_session_kind(s: &SyntaxSession) -> HighlighterKind {
497    let mut any = false;
498    for bd in s.blocks.values() {
499        if spans_touch_metrics(&bd.spans) {
500            return HighlighterKind::Metric;
501        }
502        any |= !bd.spans.is_empty();
503    }
504    if any {
505        HighlighterKind::PaintOnly
506    } else {
507        HighlighterKind::None
508    }
509}
510
511/// The kind a set of ranges implies, from their formats. Computed **once** at
512/// [`set_ranges`](HighlightRegistry::set_ranges) time and cached on the [`RangeSession`], so
513/// [`effective_kind`](HighlightRegistry::effective_kind) never rescans the whole vector.
514fn compute_range_kind(ranges: &[RangeHighlight]) -> HighlighterKind {
515    let mut any = false;
516    for r in ranges {
517        if format_touches_metrics(&r.format) {
518            return HighlighterKind::Metric;
519        }
520        any |= r.length > 0;
521    }
522    if any {
523        HighlighterKind::PaintOnly
524    } else {
525        HighlighterKind::None
526    }
527}
528
529/// Bucket each range into every block it overlaps, from the document's block layout at push
530/// time (`block_positions` = each `(block_id, absolute_char_start)`, **sorted by start**).
531///
532/// A block spans `[start_i, start_{i+1})` in absolute char space (the last runs to `MAX`); a
533/// range is bucketed into a block when their half-open spans intersect. Almost always that is a
534/// single block — the spell/find producers emit ranges within one paragraph — but a range that
535/// happens to straddle a boundary is added to **every** block it touches, so the per-block clip
536/// downstream sees it in each, exactly as the old full scan did.
537fn build_block_index(
538    ranges: &[RangeHighlight],
539    block_positions: &[(u64, usize)],
540) -> HashMap<usize, Vec<u32>> {
541    let mut index: HashMap<usize, Vec<u32>> = HashMap::new();
542    if block_positions.is_empty() {
543        return index;
544    }
545    for (ri, r) in ranges.iter().enumerate() {
546        let r_end = r.start.saturating_add(r.length); // exclusive
547        // The block containing `r.start`: the last block whose start <= r.start.
548        let mut bi = match block_positions.binary_search_by_key(&r.start, |&(_, p)| p) {
549            Ok(i) => i,
550            Err(i) => i.saturating_sub(1),
551        };
552        // Walk forward over every block the range still overlaps.
553        while bi < block_positions.len() {
554            let b_start = block_positions[bi].1;
555            if b_start >= r_end {
556                break; // this block (and all later ones) start past the range
557            }
558            let b_end = block_positions
559                .get(bi + 1)
560                .map(|&(_, p)| p)
561                .unwrap_or(usize::MAX);
562            // Half-open intersection [b_start, b_end) ∩ [r.start, r_end).
563            if r.start < b_end && b_start < r_end {
564                index
565                    .entry(block_positions[bi].0 as usize)
566                    .or_default()
567                    .push(ri as u32);
568            }
569            bi += 1;
570        }
571    }
572    index
573}
574
575impl HighlightRegistry {
576    /// The effective [`HighlighterKind`](enum@HighlighterKind) for a view — the join over the
577    /// sessions the mask admits. Computed **once at the snapshot root** and threaded down as a
578    /// plain value, exactly like the old single document-wide kind; a view showing only
579    /// paint-only sessions never pays the reshape path for a metric session it does not show.
580    pub(crate) fn effective_kind(&self, mask: &HighlightMask) -> HighlighterKind {
581        if mask.is_empty() {
582            return HighlighterKind::None;
583        }
584        let mut kind = HighlighterKind::None;
585        for s in &self.sessions {
586            if !mask.admits(s.id) {
587                continue;
588            }
589            let k = match &s.body {
590                SessionBody::Syntax(syn) => syntax_session_kind(syn),
591                SessionBody::Range(r) => r.kind, // cached at set_ranges — no rescan
592            };
593            kind = kind.join(k);
594            if kind == HighlighterKind::Metric {
595                break;
596            }
597        }
598        kind
599    }
600}
601
602// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
603// Merge algorithm
604// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
605
606/// Apply highlight format overrides onto a base `TextFormat`.
607fn apply_highlight(base: &TextFormat, hl: &HighlightFormat) -> TextFormat {
608    TextFormat {
609        font_family: hl.font_family.clone().or_else(|| base.font_family.clone()),
610        font_point_size: hl.font_point_size.or(base.font_point_size),
611        font_weight: hl.font_weight.or(base.font_weight),
612        font_bold: hl.font_bold.or(base.font_bold),
613        font_italic: hl.font_italic.or(base.font_italic),
614        font_underline: hl.font_underline.or(base.font_underline),
615        font_overline: hl.font_overline.or(base.font_overline),
616        font_strikeout: hl.font_strikeout.or(base.font_strikeout),
617        letter_spacing: hl.letter_spacing.or(base.letter_spacing),
618        word_spacing: hl.word_spacing.or(base.word_spacing),
619        underline_style: hl
620            .underline_style
621            .clone()
622            .or_else(|| base.underline_style.clone()),
623        vertical_alignment: hl
624            .vertical_alignment
625            .clone()
626            .or_else(|| base.vertical_alignment.clone()),
627        tooltip: hl.tooltip.clone().or_else(|| base.tooltip.clone()),
628        foreground_color: hl.foreground_color.or(base.foreground_color),
629        background_color: hl.background_color.or(base.background_color),
630        underline_color: hl.underline_color.or(base.underline_color),
631        // Anchors are not overridden by highlights.
632        anchor_href: base.anchor_href.clone(),
633        anchor_names: base.anchor_names.clone(),
634        is_anchor: base.is_anchor,
635    }
636}
637
638/// Merge a set of overlapping highlights into a single `HighlightFormat`.
639/// Later spans override earlier spans for the same field.
640fn merge_overlapping_highlights(spans: &[&HighlightSpan]) -> HighlightFormat {
641    let mut merged = HighlightFormat::default();
642    for span in spans {
643        let f = &span.format;
644        if f.foreground_color.is_some() {
645            merged.foreground_color = f.foreground_color;
646        }
647        if f.background_color.is_some() {
648            merged.background_color = f.background_color;
649        }
650        if f.underline_color.is_some() {
651            merged.underline_color = f.underline_color;
652        }
653        if f.font_family.is_some() {
654            merged.font_family = f.font_family.clone();
655        }
656        if f.font_point_size.is_some() {
657            merged.font_point_size = f.font_point_size;
658        }
659        if f.font_weight.is_some() {
660            merged.font_weight = f.font_weight;
661        }
662        if f.font_bold.is_some() {
663            merged.font_bold = f.font_bold;
664        }
665        if f.font_italic.is_some() {
666            merged.font_italic = f.font_italic;
667        }
668        if f.font_underline.is_some() {
669            merged.font_underline = f.font_underline;
670        }
671        if f.font_overline.is_some() {
672            merged.font_overline = f.font_overline;
673        }
674        if f.font_strikeout.is_some() {
675            merged.font_strikeout = f.font_strikeout;
676        }
677        if f.letter_spacing.is_some() {
678            merged.letter_spacing = f.letter_spacing;
679        }
680        if f.word_spacing.is_some() {
681            merged.word_spacing = f.word_spacing;
682        }
683        if f.underline_style.is_some() {
684            merged.underline_style = f.underline_style.clone();
685        }
686        if f.vertical_alignment.is_some() {
687            merged.vertical_alignment = f.vertical_alignment.clone();
688        }
689        if f.tooltip.is_some() {
690            merged.tooltip = f.tooltip.clone();
691        }
692    }
693    merged
694}
695
696/// Flatten a block's stored highlight spans into a list of
697/// [`PaintHighlightSpan`](crate::flow::PaintHighlightSpan)s for the
698/// paint-overlay path.
699///
700/// Only called when the active highlighter is [`HighlighterKind::PaintOnly`],
701/// so metric fields are guaranteed absent and ignored here. Overlapping
702/// spans are resolved exactly like `merge_highlight_spans` (split at every
703/// boundary, last-wins per field) so the overlay matches what the merged
704/// path would have produced. `block_len` is the block's character length.
705/// Sub-ranges with no paint field set are skipped.
706pub(crate) fn extract_paint_spans(
707    spans: &[HighlightSpan],
708    block_len: usize,
709) -> Vec<crate::flow::PaintHighlightSpan> {
710    if spans.is_empty() || block_len == 0 {
711        return Vec::new();
712    }
713
714    // Collect and dedupe all span boundaries within (0, block_len).
715    let mut boundaries = vec![0usize, block_len];
716    for s in spans {
717        let end = s.start.saturating_add(s.length);
718        if s.start > 0 && s.start < block_len {
719            boundaries.push(s.start);
720        }
721        if end > 0 && end < block_len {
722            boundaries.push(end);
723        }
724    }
725    boundaries.sort_unstable();
726    boundaries.dedup();
727
728    // Sweep the boundaries left→right, maintaining the set of spans active in the
729    // current window in ORIGINAL-INDEX order (a `BTreeSet` of indices). This
730    // replaces the former O(boundaries × spans) rescan — which re-filtered every
731    // span at every boundary and went quadratic on a block carrying thousands of
732    // ranges (a spell-checked Lorem paragraph, where every window still walked all
733    // ~m ranges) — with O(m log m + Σ|active|). The emitted spans are byte-identical:
734    // `BTreeSet` iterates indices ascending, the same order the old
735    // `spans.iter().filter()` produced, so `merge_overlapping_highlights` sees the
736    // same (last-wins) sequence. Each span is inserted and removed exactly once as
737    // the two monotonic pointers advance.
738    let n = spans.len();
739    let mut by_start: Vec<usize> = (0..n).collect();
740    by_start.sort_by_key(|&i| spans[i].start);
741    let mut by_end: Vec<usize> = (0..n).collect();
742    by_end.sort_by_key(|&i| spans[i].start.saturating_add(spans[i].length));
743
744    let mut active: std::collections::BTreeSet<usize> = std::collections::BTreeSet::new();
745    let mut ps = 0usize; // next span to activate (ordered by start)
746    let mut pe = 0usize; // next span to deactivate (ordered by end)
747    let mut scratch: Vec<&HighlightSpan> = Vec::new();
748
749    let mut result = Vec::new();
750    for w in boundaries.windows(2) {
751        let (sub_start, sub_end) = (w[0], w[1]);
752        if sub_end <= sub_start {
753            continue;
754        }
755        // A span is active in [sub_start, sub_end) iff start <= sub_start < end.
756        // Activate every span that has started by the left edge, then deactivate
757        // every span that has ended by it — add-before-remove so a zero-length
758        // span (start == end == sub_start) is excluded, matching the old strict
759        // `end > sub_start` test.
760        while ps < n && spans[by_start[ps]].start <= sub_start {
761            active.insert(by_start[ps]);
762            ps += 1;
763        }
764        while pe < n
765            && spans[by_end[pe]]
766                .start
767                .saturating_add(spans[by_end[pe]].length)
768                <= sub_start
769        {
770            active.remove(&by_end[pe]);
771            pe += 1;
772        }
773        if active.is_empty() {
774            continue;
775        }
776        scratch.clear();
777        scratch.extend(active.iter().map(|&i| &spans[i]));
778        let merged = merge_overlapping_highlights(&scratch);
779        if merged.foreground_color.is_none()
780            && merged.background_color.is_none()
781            && merged.underline_color.is_none()
782            && merged.underline_style.is_none()
783            && merged.font_underline.is_none()
784            && merged.font_overline.is_none()
785            && merged.font_strikeout.is_none()
786        {
787            continue;
788        }
789        result.push(crate::flow::PaintHighlightSpan {
790            start: sub_start,
791            length: sub_end - sub_start,
792            foreground_color: merged.foreground_color,
793            background_color: merged.background_color,
794            underline_color: merged.underline_color,
795            underline_style: merged.underline_style,
796            font_underline: merged.font_underline,
797            font_overline: merged.font_overline,
798            font_strikeout: merged.font_strikeout,
799        });
800    }
801    result
802}
803
804/// Merge highlight spans into a list of fragments.
805///
806/// Text fragments that overlap with highlight spans are split at span
807/// boundaries. The highlight format is overlaid onto the base `TextFormat`.
808/// Image fragments receive the overlay without splitting.
809/// Local copy of the word-start computation from `text_block.rs`:
810/// returns character indices (not byte offsets) where a Unicode word
811/// starts, per UAX #29. Mirrors the upstream helper so highlight
812/// splits produce accessibility-correct word_starts for each
813/// sub-fragment without reaching into `text_block`.
814fn compute_word_starts_local(text: &str) -> Vec<u8> {
815    use unicode_segmentation::UnicodeSegmentation;
816    let mut result = Vec::new();
817    let mut byte_to_char: Vec<(usize, usize)> = Vec::new();
818    for (ci, (bi, _)) in text.char_indices().enumerate() {
819        byte_to_char.push((bi, ci));
820    }
821    for (byte_off, _word) in text.unicode_word_indices() {
822        let char_idx = byte_to_char
823            .iter()
824            .find(|(bi, _)| *bi == byte_off)
825            .map(|(_, ci)| *ci)
826            .unwrap_or(0);
827        if let Ok(idx) = u8::try_from(char_idx) {
828            result.push(idx);
829        } else {
830            break;
831        }
832    }
833    result
834}
835
836pub(crate) fn merge_highlight_spans(
837    fragments: Vec<FragmentContent>,
838    spans: &[HighlightSpan],
839) -> Vec<FragmentContent> {
840    if spans.is_empty() {
841        return fragments;
842    }
843
844    let mut result = Vec::with_capacity(fragments.len());
845
846    for frag in fragments {
847        match frag {
848            FragmentContent::Text {
849                ref text,
850                ref format,
851                offset,
852                length,
853                element_id,
854                word_starts: _,
855            } => {
856                let frag_end = offset + length;
857
858                // Collect highlight boundaries within this fragment's range.
859                let mut boundaries = Vec::new();
860                boundaries.push(offset);
861                boundaries.push(frag_end);
862
863                for span in spans {
864                    let span_end = span.start + span.length;
865                    // Does this span overlap the fragment?
866                    if span.start < frag_end && span_end > offset {
867                        if span.start > offset && span.start < frag_end {
868                            boundaries.push(span.start);
869                        }
870                        if span_end > offset && span_end < frag_end {
871                            boundaries.push(span_end);
872                        }
873                    }
874                }
875
876                boundaries.sort_unstable();
877                boundaries.dedup();
878
879                // Split the text at each boundary and apply overlapping highlights.
880                let chars: Vec<char> = text.chars().collect();
881                for window in boundaries.windows(2) {
882                    let sub_start = window[0];
883                    let sub_end = window[1];
884                    let sub_len = sub_end - sub_start;
885                    if sub_len == 0 {
886                        continue;
887                    }
888
889                    // Collect all highlight spans overlapping [sub_start, sub_end).
890                    let active: Vec<&HighlightSpan> = spans
891                        .iter()
892                        .filter(|s| {
893                            let s_end = s.start + s.length;
894                            s.start < sub_end && s_end > sub_start
895                        })
896                        .collect();
897
898                    let char_start = sub_start - offset;
899                    let char_end = char_start + sub_len;
900                    let sub_text: String = chars[char_start..char_end].iter().collect();
901
902                    let sub_format = if active.is_empty() {
903                        format.clone()
904                    } else {
905                        let merged_hl = merge_overlapping_highlights(&active);
906                        apply_highlight(format, &merged_hl)
907                    };
908
909                    let sub_word_starts = compute_word_starts_local(&sub_text);
910                    result.push(FragmentContent::Text {
911                        text: sub_text,
912                        format: sub_format,
913                        offset: sub_start,
914                        length: sub_len,
915                        // All sub-fragments split from one source
916                        // `FragmentContent::Text` reference the same
917                        // underlying format run — only the highlight
918                        // formatting differs. Sharing the id is
919                        // correct for accessibility (the underlying
920                        // text belongs to one stable run) at the cost
921                        // that synthetic NodeIds for highlighted
922                        // sub-runs collide unless the caller further
923                        // disambiguates.
924                        // The bastyde-widgets layer handles that by
925                        // mixing the `offset` into the synthetic-id
926                        // hash alongside `element_id`.
927                        element_id,
928                        word_starts: sub_word_starts,
929                    });
930                }
931            }
932            FragmentContent::Image {
933                ref name,
934                width,
935                height,
936                quality,
937                ref format,
938                offset,
939                element_id,
940            } => {
941                // Find overlapping highlights for this single-char position.
942                let active: Vec<&HighlightSpan> = spans
943                    .iter()
944                    .filter(|s| {
945                        let s_end = s.start + s.length;
946                        s.start < offset + 1 && s_end > offset
947                    })
948                    .collect();
949
950                let img_format = if active.is_empty() {
951                    format.clone()
952                } else {
953                    let merged_hl = merge_overlapping_highlights(&active);
954                    apply_highlight(format, &merged_hl)
955                };
956
957                result.push(FragmentContent::Image {
958                    name: name.clone(),
959                    width,
960                    height,
961                    quality,
962                    format: img_format,
963                    offset,
964                    element_id,
965                });
966            }
967        }
968    }
969
970    result
971}
972
973// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
974// Re-highlighting
975// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
976
977/// Every block's id + text, sorted by `document_position`. The text materialization is the
978/// expensive part (rope → String for the whole document), so callers that only need block
979/// *positions* use [`ordered_block_positions`] instead.
980fn ordered_block_ids(inner: &TextDocumentInner) -> Vec<(u64, String)> {
981    let mut blocks = block_commands::get_all_block(&inner.ctx).unwrap_or_default();
982    let store = inner.ctx.db_context.get_store();
983    crate::inner::refresh_block_positions(&mut blocks, store);
984    blocks.sort_by_key(|b| b.document_position);
985    blocks
986        .into_iter()
987        .map(|b| {
988            let entity: common::entities::Block = b.clone().into();
989            let text = common::database::rope_helpers::block_content_via_store(&entity, store);
990            (b.id, text)
991        })
992        .collect()
993}
994
995/// Every block's id + absolute char start (`document_position`), sorted — **without**
996/// materializing any block text. This is the cheap sibling of [`ordered_block_ids`]; the
997/// double full-document scan it exists to prevent is described on [`TextDocumentInner::rehighlight_affected`].
998pub(crate) fn ordered_block_positions(inner: &TextDocumentInner) -> Vec<(u64, usize)> {
999    let mut blocks = block_commands::get_all_block(&inner.ctx).unwrap_or_default();
1000    let store = inner.ctx.db_context.get_store();
1001    crate::inner::refresh_block_positions(&mut blocks, store);
1002    blocks.sort_by_key(|b| b.document_position);
1003    blocks
1004        .into_iter()
1005        .map(|b| (b.id, b.document_position.max(0) as usize))
1006        .collect()
1007}
1008
1009/// A block's absolute char start and char length — the geometry a range session needs to
1010/// slice its absolute ranges to this block. `document_position` is the block's absolute char
1011/// start, exactly as the find/replace path resolves offsets against it.
1012fn block_geometry(inner: &TextDocumentInner, block_id: usize) -> (usize, usize) {
1013    let store = inner.ctx.db_context.get_store();
1014    let Some(mut dto) = block_commands::get_block(&inner.ctx, &(block_id as u64))
1015        .ok()
1016        .flatten()
1017    else {
1018        return (0, 0);
1019    };
1020    crate::inner::refresh_block_position(&mut dto, store);
1021    let abs_start = dto.document_position.max(0) as usize;
1022    let entity: common::entities::Block = dto.into();
1023    let len = common::database::rope_helpers::block_char_length(&entity, store).max(0) as usize;
1024    (abs_start, len)
1025}
1026
1027/// The block-relative highlight spans a **view** sees for one block: every session the mask
1028/// admits, in registration order (so a later session's field overrides an earlier one), with
1029/// range sessions' absolute ranges sliced to this block.
1030///
1031/// The block geometry needed to slice range sessions is fetched **lazily** — a document with
1032/// only syntax sessions (or a mask that admits none) pays nothing for it.
1033pub(crate) fn merged_spans_for_block(
1034    inner: &TextDocumentInner,
1035    block_id: usize,
1036    mask: &HighlightMask,
1037) -> Vec<HighlightSpan> {
1038    if mask.is_empty() || inner.highlights.is_empty() {
1039        return Vec::new();
1040    }
1041
1042    let mut geom: Option<(usize, usize)> = None;
1043    let mut out: Vec<HighlightSpan> = Vec::new();
1044
1045    for s in &inner.highlights.sessions {
1046        if !mask.admits(s.id) {
1047            continue;
1048        }
1049        match &s.body {
1050            SessionBody::Syntax(syn) => {
1051                if let Some(bd) = syn.blocks.get(&block_id) {
1052                    out.extend(bd.spans.iter().cloned());
1053                }
1054            }
1055            SessionBody::Range(r) => {
1056                // Only the ranges the index says touch this block — O(ranges-in-block), not
1057                // O(all-ranges). A block absent from the index (empty bucket) contributes
1058                // nothing; see [`RangeSession::block_index`] on the missing-vs-stale window.
1059                let Some(indices) = r.block_index.get(&block_id) else {
1060                    continue;
1061                };
1062                let (abs_start, len) = *geom.get_or_insert_with(|| block_geometry(inner, block_id));
1063                let block_end = abs_start + len;
1064                for &ri in indices {
1065                    let rng = &r.ranges[ri as usize];
1066                    // Saturating: a range session's offsets come from the host (an externally
1067                    // driven spell-checker included), so a wild `start + length` must clamp, not
1068                    // overflow-panic in debug / wrap in release.
1069                    let lo = rng.start.max(abs_start);
1070                    let hi = rng.start.saturating_add(rng.length).min(block_end);
1071                    if lo < hi {
1072                        out.push(HighlightSpan {
1073                            start: lo - abs_start,
1074                            length: hi - lo,
1075                            format: rng.format.clone(),
1076                        });
1077                    }
1078                }
1079            }
1080        }
1081    }
1082    out
1083}
1084
1085impl TextDocumentInner {
1086    /// Re-highlight every block, for every **syntax** session (range sessions carry their
1087    /// ranges directly and never run a callback).
1088    ///
1089    /// Each syntax session runs its **own** cascade — its own `previous_state` timeline,
1090    /// reset to −1 here, and its own per-block cache. State never crosses between sessions, or
1091    /// one highlighter's multiline-comment run would corrupt another's. The block text is
1092    /// materialized **once** and shared across sessions, though: the rope → String scan is the
1093    /// cost, and running it per session would be N× for no reason.
1094    pub(crate) fn rehighlight_all(&mut self) {
1095        if self.highlights.is_empty() {
1096            self.recompute_highlight_kind();
1097            return;
1098        }
1099        let blocks = ordered_block_ids(self);
1100
1101        for si in 0..self.highlights.sessions.len() {
1102            let SessionBody::Syntax(syn) = &self.highlights.sessions[si].body else {
1103                continue;
1104            };
1105            let highlighter = Arc::clone(&syn.highlighter);
1106
1107            let mut fresh: HashMap<usize, BlockHighlightData> = HashMap::new();
1108            let mut previous_state: i64 = -1;
1109            for (block_id, text) in &blocks {
1110                let bid = *block_id as usize;
1111                let mut ctx = HighlightContext::new(bid, previous_state, None);
1112                highlighter.highlight_block(text, &mut ctx);
1113                let (spans, state, user_data) = ctx.into_parts();
1114                previous_state = state;
1115                fresh.insert(
1116                    bid,
1117                    BlockHighlightData {
1118                        spans,
1119                        state,
1120                        user_data,
1121                    },
1122                );
1123            }
1124            if let SessionBody::Syntax(syn) = &mut self.highlights.sessions[si].body {
1125                syn.blocks = fresh;
1126            }
1127        }
1128
1129        self.recompute_highlight_kind();
1130    }
1131
1132    /// Recompute the cached document-wide [`highlight_kind`](TextDocumentInner::highlight_kind)
1133    /// — the effective kind under the all-sessions mask, which is what the unmasked
1134    /// `snapshot_flow()` uses. Masked snapshots derive their own kind at the root.
1135    pub(crate) fn recompute_highlight_kind(&mut self) {
1136        self.highlight_kind = self.highlights.effective_kind(&HighlightMask::all());
1137    }
1138
1139    /// This syntax session's cached block state (−1 if unset / not a syntax session).
1140    fn syntax_block_state(&self, session_idx: usize, block_id: usize) -> i64 {
1141        match &self.highlights.sessions[session_idx].body {
1142            SessionBody::Syntax(s) => s.blocks.get(&block_id).map_or(-1, |d| d.state),
1143            _ => -1,
1144        }
1145    }
1146
1147    /// Re-highlight from a block for every syntax session, cascading each until its own state
1148    /// stabilizes.
1149    pub(crate) fn rehighlight_from_block(&mut self, start_block_id: usize) {
1150        if self.highlights.is_empty() {
1151            return;
1152        }
1153        let blocks = ordered_block_ids(self);
1154        let Some(start_idx) = blocks
1155            .iter()
1156            .position(|(id, _)| *id as usize == start_block_id)
1157        else {
1158            return;
1159        };
1160
1161        for si in 0..self.highlights.sessions.len() {
1162            if matches!(self.highlights.sessions[si].body, SessionBody::Syntax(_)) {
1163                self.rehighlight_session_from(si, start_idx, &blocks);
1164            }
1165        }
1166
1167        self.recompute_highlight_kind();
1168    }
1169
1170    /// One syntax session's cascade from `start_idx` (see [`Self::rehighlight_from_block`]).
1171    fn rehighlight_session_from(
1172        &mut self,
1173        session_idx: usize,
1174        start_idx: usize,
1175        blocks: &[(u64, String)],
1176    ) {
1177        let highlighter = match &self.highlights.sessions[session_idx].body {
1178            SessionBody::Syntax(s) => Arc::clone(&s.highlighter),
1179            _ => return,
1180        };
1181
1182        for i in start_idx..blocks.len() {
1183            let (block_id, ref text) = blocks[i];
1184            let bid = block_id as usize;
1185
1186            let previous_state = if i == 0 {
1187                -1
1188            } else {
1189                self.syntax_block_state(session_idx, blocks[i - 1].0 as usize)
1190            };
1191
1192            // Reuse the block's existing user data (this session's own).
1193            let user_data = match &mut self.highlights.sessions[session_idx].body {
1194                SessionBody::Syntax(s) => s.blocks.get_mut(&bid).and_then(|d| d.user_data.take()),
1195                _ => None,
1196            };
1197            let old_state = self.syntax_block_state(session_idx, bid);
1198
1199            let mut ctx = HighlightContext::new(bid, previous_state, user_data);
1200            highlighter.highlight_block(text, &mut ctx);
1201            let (spans, state, user_data) = ctx.into_parts();
1202
1203            if let SessionBody::Syntax(s) = &mut self.highlights.sessions[session_idx].body {
1204                s.blocks.insert(
1205                    bid,
1206                    BlockHighlightData {
1207                        spans,
1208                        state,
1209                        user_data,
1210                    },
1211                );
1212            }
1213
1214            // Past the start and the state is unchanged: this session's cascade has settled.
1215            if i > start_idx && state == old_state {
1216                break;
1217            }
1218        }
1219    }
1220
1221    /// Re-highlight the blocks a content change at `position` affects.
1222    ///
1223    /// The target block is found from a **positions-only** scan (no block text materialized),
1224    /// then [`rehighlight_from_block`](Self::rehighlight_from_block) does the one text scan it
1225    /// needs. The old code materialized every block's text here *just to locate one block*, and
1226    /// `rehighlight_from_block` then materialized them all again — two full-document rope →
1227    /// String passes on every single keystroke, at N=1.
1228    pub(crate) fn rehighlight_affected(&mut self, position: usize) {
1229        if self.highlights.is_empty() {
1230            return;
1231        }
1232        let positions = ordered_block_positions(self);
1233        if positions.is_empty() {
1234            return;
1235        }
1236        // The last block whose start is at or before `position` contains it.
1237        let target_bid = positions
1238            .iter()
1239            .rev()
1240            .find(|(_, bp)| position >= *bp)
1241            .map(|(id, _)| *id as usize)
1242            .unwrap_or_else(|| positions[0].0 as usize);
1243
1244        self.rehighlight_from_block(target_bid);
1245    }
1246}
1247
1248#[cfg(test)]
1249mod paint_span_tests {
1250    use super::*;
1251
1252    /// The previous O(boundaries × spans) implementation, kept verbatim as the
1253    /// oracle the sweep must reproduce byte-for-byte.
1254    fn extract_paint_spans_reference(
1255        spans: &[HighlightSpan],
1256        block_len: usize,
1257    ) -> Vec<crate::flow::PaintHighlightSpan> {
1258        if spans.is_empty() || block_len == 0 {
1259            return Vec::new();
1260        }
1261        let mut boundaries = vec![0usize, block_len];
1262        for s in spans {
1263            let end = s.start.saturating_add(s.length);
1264            if s.start > 0 && s.start < block_len {
1265                boundaries.push(s.start);
1266            }
1267            if end > 0 && end < block_len {
1268                boundaries.push(end);
1269            }
1270        }
1271        boundaries.sort_unstable();
1272        boundaries.dedup();
1273        let mut result = Vec::new();
1274        for w in boundaries.windows(2) {
1275            let (sub_start, sub_end) = (w[0], w[1]);
1276            if sub_end <= sub_start {
1277                continue;
1278            }
1279            let active: Vec<&HighlightSpan> = spans
1280                .iter()
1281                .filter(|s| s.start < sub_end && s.start + s.length > sub_start)
1282                .collect();
1283            if active.is_empty() {
1284                continue;
1285            }
1286            let merged = merge_overlapping_highlights(&active);
1287            if merged.foreground_color.is_none()
1288                && merged.background_color.is_none()
1289                && merged.underline_color.is_none()
1290                && merged.underline_style.is_none()
1291                && merged.font_underline.is_none()
1292                && merged.font_overline.is_none()
1293                && merged.font_strikeout.is_none()
1294            {
1295                continue;
1296            }
1297            result.push(crate::flow::PaintHighlightSpan {
1298                start: sub_start,
1299                length: sub_end - sub_start,
1300                foreground_color: merged.foreground_color,
1301                background_color: merged.background_color,
1302                underline_color: merged.underline_color,
1303                underline_style: merged.underline_style,
1304                font_underline: merged.font_underline,
1305                font_overline: merged.font_overline,
1306                font_strikeout: merged.font_strikeout,
1307            });
1308        }
1309        result
1310    }
1311
1312    /// A span whose single paint field is keyed by `k`, so that last-wins merging
1313    /// across overlaps produces observably different output when the active-set
1314    /// ORDER is wrong — the property most at risk in the rewrite.
1315    fn span(start: usize, length: usize, k: usize) -> HighlightSpan {
1316        let c = |v: usize| crate::Color {
1317            red: v as u8,
1318            green: (v >> 8) as u8,
1319            blue: 7,
1320            alpha: 255,
1321        };
1322        let format = match k % 4 {
1323            0 => HighlightFormat {
1324                background_color: Some(c(k)),
1325                ..Default::default()
1326            },
1327            1 => HighlightFormat {
1328                foreground_color: Some(c(k)),
1329                ..Default::default()
1330            },
1331            2 => HighlightFormat {
1332                underline_color: Some(c(k)),
1333                ..Default::default()
1334            },
1335            // No paint field: must be dropped by both implementations.
1336            _ => HighlightFormat {
1337                font_bold: Some(true),
1338                ..Default::default()
1339            },
1340        };
1341        HighlightSpan {
1342            start,
1343            length,
1344            format,
1345        }
1346    }
1347
1348    #[test]
1349    fn sweep_matches_reference_on_edge_cases() {
1350        let cases: Vec<(Vec<HighlightSpan>, usize)> = vec![
1351            (vec![], 10),
1352            (vec![span(0, 4, 0)], 0),                  // empty block
1353            (vec![span(0, 5, 0)], 10),                 // single
1354            (vec![span(0, 3, 0), span(5, 3, 1)], 10),  // disjoint
1355            (vec![span(2, 5, 0), span(4, 5, 1)], 12),  // overlap: later wins in [4,7)
1356            (vec![span(0, 10, 0), span(3, 2, 1)], 10), // nested
1357            (vec![span(0, 3, 0), span(3, 3, 1)], 10),  // adjacent (touch, no overlap)
1358            (vec![span(4, 0, 0)], 10),                 // zero-length → nothing
1359            (vec![span(0, 4, 3)], 10),                 // no paint field → dropped
1360            (vec![span(8, 5, 0)], 10),                 // spills past block_len
1361            (vec![span(12, 3, 0)], 10),                // entirely past block_len
1362            (vec![span(0, 4, 0), span(0, 4, 1), span(0, 4, 2)], 10), // coincident, order matters
1363        ];
1364        for (i, (spans, len)) in cases.iter().enumerate() {
1365            assert_eq!(
1366                extract_paint_spans(spans, *len),
1367                extract_paint_spans_reference(spans, *len),
1368                "edge case {i}: spans={spans:?} block_len={len}"
1369            );
1370        }
1371    }
1372
1373    #[test]
1374    fn sweep_matches_reference_randomized() {
1375        // Deterministic LCG — no rng dependency, reproducible across runs.
1376        let mut state: u64 = 0x9E37_79B9_7F4A_7C15;
1377        let mut next = |bound: usize| -> usize {
1378            state = state
1379                .wrapping_mul(6364136223846793005)
1380                .wrapping_add(1442695040888963407);
1381            ((state >> 33) as usize) % bound.max(1)
1382        };
1383        for trial in 0..2000 {
1384            let block_len = 1 + next(40);
1385            let m = next(14); // 0..13 spans, a mix of overlap densities
1386            let mut spans = Vec::with_capacity(m);
1387            for k in 0..m {
1388                let start = next(block_len + 4); // sometimes at/past the end
1389                let length = next(block_len + 2); // sometimes zero, sometimes spilling over
1390                spans.push(span(start, length, trial + k));
1391            }
1392            assert_eq!(
1393                extract_paint_spans(&spans, block_len),
1394                extract_paint_spans_reference(&spans, block_len),
1395                "trial {trial}: spans={spans:?} block_len={block_len}"
1396            );
1397        }
1398    }
1399}
1400
1401#[cfg(test)]
1402mod index_tests {
1403    use super::*;
1404
1405    fn paint(start: usize, length: usize) -> RangeHighlight {
1406        RangeHighlight {
1407            start,
1408            length,
1409            format: HighlightFormat {
1410                background_color: Some(crate::Color {
1411                    red: 255,
1412                    green: 0,
1413                    blue: 0,
1414                    alpha: 255,
1415                }),
1416                ..Default::default()
1417            },
1418        }
1419    }
1420
1421    fn metric(start: usize, length: usize) -> RangeHighlight {
1422        RangeHighlight {
1423            start,
1424            length,
1425            format: HighlightFormat {
1426                font_bold: Some(true),
1427                ..Default::default()
1428            },
1429        }
1430    }
1431
1432    // ── compute_range_kind (B2-M2): the cached kind matches the old per-range classification ──
1433
1434    #[test]
1435    fn kind_is_none_when_nothing_paints() {
1436        assert_eq!(compute_range_kind(&[]), HighlighterKind::None);
1437        // A zero-length paint range colours nothing → None.
1438        assert_eq!(compute_range_kind(&[paint(3, 0)]), HighlighterKind::None);
1439    }
1440
1441    #[test]
1442    fn kind_is_paint_only_for_a_background_range() {
1443        assert_eq!(
1444            compute_range_kind(&[paint(0, 4)]),
1445            HighlighterKind::PaintOnly
1446        );
1447    }
1448
1449    #[test]
1450    fn kind_is_metric_when_any_range_touches_metrics() {
1451        // One metric range among paint ones lifts the whole session to Metric (a bold run
1452        // reshapes, so the view must take the reshape path).
1453        assert_eq!(
1454            compute_range_kind(&[paint(0, 2), metric(4, 3), paint(8, 1)]),
1455            HighlighterKind::Metric
1456        );
1457    }
1458
1459    #[test]
1460    fn set_ranges_caches_the_kind_read_back_by_effective_kind() {
1461        let mut reg = HighlightRegistry::default();
1462        let id = reg.add_range();
1463        let positions = [(0u64, 0usize)];
1464        assert_eq!(
1465            reg.effective_kind(&HighlightMask::all()),
1466            HighlighterKind::None
1467        );
1468
1469        reg.set_ranges(id, vec![paint(0, 5)], &positions);
1470        assert_eq!(
1471            reg.effective_kind(&HighlightMask::all()),
1472            HighlighterKind::PaintOnly
1473        );
1474
1475        reg.set_ranges(id, vec![metric(0, 5)], &positions);
1476        assert_eq!(
1477            reg.effective_kind(&HighlightMask::all()),
1478            HighlighterKind::Metric,
1479            "the cached kind updates on every push"
1480        );
1481    }
1482
1483    // ── build_block_index: bucketing ──
1484
1485    /// Blocks at 0, 10, 20 (each 10 wide). Ranges land in the block(s) they overlap.
1486    fn three_blocks() -> Vec<(u64, usize)> {
1487        vec![(100, 0), (101, 10), (102, 20)]
1488    }
1489
1490    fn bucket(index: &std::collections::HashMap<usize, Vec<u32>>, block: usize) -> Vec<u32> {
1491        index.get(&block).cloned().unwrap_or_default()
1492    }
1493
1494    #[test]
1495    fn a_range_lands_only_in_its_own_block() {
1496        let idx = build_block_index(&[paint(12, 3)], &three_blocks());
1497        assert_eq!(
1498            bucket(&idx, 101),
1499            vec![0],
1500            "12..15 is inside block 101 [10,20)"
1501        );
1502        assert!(bucket(&idx, 100).is_empty());
1503        assert!(bucket(&idx, 102).is_empty());
1504    }
1505
1506    #[test]
1507    fn a_straddling_range_lands_in_every_block_it_touches() {
1508        // 8..22 spans blocks 100 [0,10), 101 [10,20), 102 [20,∞).
1509        let idx = build_block_index(&[paint(8, 14)], &three_blocks());
1510        assert_eq!(bucket(&idx, 100), vec![0]);
1511        assert_eq!(bucket(&idx, 101), vec![0]);
1512        assert_eq!(bucket(&idx, 102), vec![0]);
1513    }
1514
1515    #[test]
1516    fn a_zero_length_range_buckets_into_its_block_but_paints_nothing() {
1517        // A degenerate zero-length range still buckets into the block that contains its point
1518        // (here 101), which is harmless: the per-block clip drops it (lo == hi), so it paints
1519        // nothing — proven end-to-end by the coverage differential in the integration tests.
1520        // It must not scatter into other blocks.
1521        let idx = build_block_index(&[paint(12, 0)], &three_blocks());
1522        assert_eq!(bucket(&idx, 101), vec![0]);
1523        assert!(bucket(&idx, 100).is_empty());
1524        assert!(bucket(&idx, 102).is_empty());
1525    }
1526
1527    #[test]
1528    fn an_out_of_range_start_is_bucketed_into_the_last_block_only_if_it_overlaps() {
1529        // start far past the end: only the last (unbounded) block could contain it, and it does
1530        // — the last block runs to usize::MAX — so it buckets there. That is harmless: the
1531        // per-block clip against real geometry drops it (start > block_end).
1532        let idx = build_block_index(&[paint(9999, 3)], &three_blocks());
1533        assert_eq!(
1534            bucket(&idx, 102),
1535            vec![0],
1536            "the unbounded last block is the only candidate"
1537        );
1538        assert!(bucket(&idx, 100).is_empty());
1539        assert!(bucket(&idx, 101).is_empty());
1540    }
1541
1542    #[test]
1543    fn empty_positions_yields_an_empty_index() {
1544        assert!(build_block_index(&[paint(0, 5)], &[]).is_empty());
1545    }
1546}