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/// Whether a session is shown to every view or only to the ones that ask for it.
185///
186/// The default is [`Shared`](SessionVisibility::Shared), and it is the right default: a
187/// spell-checker's squiggles and a comment's underline are facts about the *text*, so every
188/// view of that text should draw them.
189///
190/// [`OptIn`](SessionVisibility::OptIn) is for a layer that is a fact about **one view**
191/// rather than about the text: a reading that marks every mention of one character, say.
192/// Such a layer still lives on the document (there is nowhere else for a range session to
193/// live), but a mask has to name it before anything renders it, so a second view of the same
194/// document is left alone. Without this the only way to keep a layer out of a sibling view
195/// would be [`HighlightMask::only`], which means naming *every* session the view does want,
196/// including ones it has no handle on, such as the widget's own ambient caret band.
197#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
198pub enum SessionVisibility {
199    /// Rendered by every view whose mask does not exclude it. The default.
200    #[default]
201    Shared,
202    /// Rendered only by a view whose mask names it. See [`HighlightMask::with`].
203    OptIn,
204}
205
206/// Which highlight sessions a particular **view** renders.
207///
208/// Two panes over one shared document can carry different find queries, so "which highlights
209/// to show" is a property of the *view*, not the document. A snapshot is built under a mask;
210/// only the sessions the mask admits contribute spans, and the effective
211/// `HighlighterKind` is the join over just those.
212///
213/// The default ([`HighlightMask::all`]) shows every **shared** session, the behaviour of the
214/// old `snapshot_flow()`. [`HighlightMask::none`] shows none, the old
215/// `snapshot_flow_without_highlights()`, and it must stay exactly as cheap, since every
216/// read-only preview pane uses it. An [`OptIn`](SessionVisibility::OptIn) session is shown by
217/// neither until [`with`](HighlightMask::with) names it.
218#[derive(Debug, Clone, Default, PartialEq, Eq)]
219pub struct HighlightMask {
220    /// `None` = admit every shared session (the default). `Some(set)` = admit only these ids,
221    /// whatever their visibility, since an explicit list is an explicit choice.
222    included: Option<Vec<SessionId>>,
223    /// [`OptIn`](SessionVisibility::OptIn) sessions this view asked for by name. Only
224    /// consulted when [`Self::included`] is `None`; a `Some` list already names everything
225    /// this view renders.
226    opted_in: Vec<SessionId>,
227}
228
229impl HighlightMask {
230    /// The all-admitting mask as a `const`, so a snapshot builder can reference "show
231    /// everything shared" with a `'static` lifetime, with no temporary to outlive.
232    pub(crate) const ALL: HighlightMask = HighlightMask {
233        included: None,
234        opted_in: Vec::new(),
235    };
236
237    /// Show every **shared** session attached to the document. The default, and the shape of
238    /// a plain `snapshot_flow()`. An [`OptIn`](SessionVisibility::OptIn) session needs
239    /// [`with`](Self::with) on top.
240    pub fn all() -> Self {
241        Self::ALL
242    }
243
244    /// Show no highlights at all — as cheap as the old `show_highlights = false`.
245    pub fn none() -> Self {
246        Self {
247            included: Some(Vec::new()),
248            opted_in: Vec::new(),
249        }
250    }
251
252    /// Show only the named sessions (e.g. the shared syntax + spell sessions plus *this*
253    /// view's own find session), whatever their visibility: naming a session **is** asking
254    /// for it, so an [`OptIn`](SessionVisibility::OptIn) id listed here is admitted.
255    pub fn only(ids: impl IntoIterator<Item = SessionId>) -> Self {
256        Self {
257            included: Some(ids.into_iter().collect()),
258            opted_in: Vec::new(),
259        }
260    }
261
262    /// Ask for one more session by name.
263    ///
264    /// On a mask that already names a set ([`only`](Self::only) / [`none`](Self::none)) this
265    /// adds to it. On [`all`](Self::all) it admits one
266    /// [`OptIn`](SessionVisibility::OptIn) session **without narrowing anything else**,
267    /// which is the shape a view wants when it owns a private layer and still wants the
268    /// spell squiggles, comment underlines and ambient caret band it holds no handle on.
269    pub fn with(mut self, id: SessionId) -> Self {
270        let bucket = match &mut self.included {
271            Some(ids) => ids,
272            None => &mut self.opted_in,
273        };
274        if !bucket.contains(&id) {
275            bucket.push(id);
276        }
277        self
278    }
279
280    /// Whether this mask admits a session with this id and this visibility.
281    pub(crate) fn admits(&self, id: SessionId, visibility: SessionVisibility) -> bool {
282        match &self.included {
283            Some(ids) => ids.contains(&id),
284            None => match visibility {
285                SessionVisibility::Shared => true,
286                SessionVisibility::OptIn => self.opted_in.contains(&id),
287            },
288        }
289    }
290
291    /// Whether this mask admits nothing — the fast path an empty/no-op preview takes, which
292    /// must be exactly as cheap as the old boolean `false`.
293    ///
294    /// `opted_in` is deliberately not consulted: it is only read when `included` is `None`,
295    /// and a `None` mask always admits every shared session, so it is never empty.
296    pub(crate) fn is_empty(&self) -> bool {
297        matches!(&self.included, Some(ids) if ids.is_empty())
298    }
299}
300
301/// What a snapshot renders, resolved **once at the root** and threaded down unchanged: the
302/// effective [`HighlighterKind`](enum@HighlighterKind) (the join over the view's admitted
303/// sessions) and the mask that selected them.
304///
305/// This replaces the plain `effective_kind: HighlighterKind` the block/frame builders used to
306/// take — carrying the mask alongside so the leaf that resolves a block's spans knows which
307/// sessions this view shows, without re-deriving the kind per block.
308#[derive(Clone, Copy)]
309pub(crate) struct SnapshotHighlights<'a> {
310    pub kind: HighlighterKind,
311    pub mask: &'a HighlightMask,
312    /// Skip the paint-only overlay (`paint_highlights`) entirely: fragments are
313    /// still split for metric sessions, but no `extract_paint_spans` runs. For
314    /// consumers that read only the fragments/geometry and discard the visual
315    /// overlay — the accessibility tree above all — this drops the whole
316    /// paint-span computation, which is O(spans) per block (superlinear when a
317    /// block carries thousands of ranges, e.g. a spell-checked Lorem scene).
318    /// The produced `fragments` are byte-identical to a normal snapshot; only
319    /// `paint_highlights` differs (always empty here).
320    pub suppress_paint: bool,
321}
322
323// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
324// Internal storage
325// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
326
327/// Per-block highlight state.
328pub(crate) struct BlockHighlightData {
329    pub spans: Vec<HighlightSpan>,
330    pub state: i64,
331    pub user_data: Option<Box<dyn Any + Send + Sync>>,
332}
333
334/// A **syntax session**: a callback highlighter and its per-block cascade cache. This is
335/// exactly the old single-highlighter storage — one `SyntaxHighlighter` invoked once per
336/// block, with its own `previous_state`/`current_state` timeline and per-block user data.
337///
338/// Each syntax session owns its cascade **independently**. A multiline-comment state from one
339/// highlighter must never leak into another's `previous_block_state`, so the state is threaded
340/// per session, never shared.
341pub(crate) struct SyntaxSession {
342    pub highlighter: Arc<dyn SyntaxHighlighter>,
343    pub blocks: HashMap<usize, BlockHighlightData>,
344}
345
346/// A **range session**: absolute-offset ranges, set wholesale by the host and sliced to
347/// per-block spans on demand. No callback, no cascade — used for find and spell.
348///
349/// The ranges carry a **per-block index** and a **cached kind**, both built once by
350/// [`set_ranges`](HighlightRegistry::set_ranges) from the document's block layout at push time.
351/// They replace two per-query full scans of the whole range vector that made a snapshot
352/// O(blocks × ranges): a spell-check of a Lorem-Ipsum-dense document flags one range per word
353/// (tens of thousands of them), and *every* block used to walk *all* of them.
354pub(crate) struct RangeSession {
355    pub ranges: Vec<RangeHighlight>,
356    /// `block_id → indices into `ranges` that overlap that block`, at the block layout of the
357    /// last push. A block **absent** from the map has no ranges for this session.
358    ///
359    /// Freshness: the ranges' absolute offsets and this index are a matched snapshot of one
360    /// push. If the document is edited *without* a following push, both go stale together — the
361    /// same window the un-indexed code already had (it, too, clipped last-push absolute ranges
362    /// against fresh geometry). The one new shape is **missing vs. stale**: a block *created*
363    /// since the last push has no key here, so it shows nothing (rather than a stale span) until
364    /// the next push rebuilds the index. For the spell producer a structural edit re-tokenises
365    /// and re-pushes on the next frame, closing the window; a brand-new empty block has nothing
366    /// to flag anyway.
367    block_index: HashMap<usize, Vec<u32>>,
368    /// The session's [`HighlighterKind`], computed in the same pass as `block_index` so
369    /// [`effective_kind`](HighlightRegistry::effective_kind) is O(1) per session instead of a
370    /// full range scan on every snapshot root.
371    kind: HighlighterKind,
372}
373
374/// The two session shapes.
375pub(crate) enum SessionBody {
376    Syntax(SyntaxSession),
377    Range(RangeSession),
378}
379
380/// One registered session with its stable id and merge priority.
381pub(crate) struct Session {
382    pub id: SessionId,
383    /// Where this session sits in the merge order — see [`HighlightRegistry::sessions`].
384    pub priority: i32,
385    /// Whether every view draws this layer, or only the ones that name it. See
386    /// [`SessionVisibility`].
387    pub visibility: SessionVisibility,
388    pub body: SessionBody,
389}
390
391/// Every highlight session on the document, sorted by **`(priority, id)`** — which is the merge
392/// order: when two sessions format the same character, the one later in this vector wins, field
393/// by field (see [`merge_overlapping_highlights`]). Replaces the old single
394/// `Option<HighlightData>` slot.
395///
396/// Priority defaults to `0`, where the order degenerates to registration order — the behaviour
397/// every existing caller had. It exists because registration order alone is not something a
398/// layer can rely on: an editor that attaches a background layer when its *view* is created
399/// registers after or before a find session purely according to which the user opened first, so
400/// "the find match paints over the ambient band" would hold in one window and not in the next.
401/// A layer that must lose declares a negative priority and stops caring when it was added.
402#[derive(Default)]
403pub(crate) struct HighlightRegistry {
404    pub sessions: Vec<Session>,
405    next_id: u64,
406    /// The session owned by the classic single-highlighter `set_syntax_highlighter` shim, if
407    /// one is installed. Kept apart so the shim replaces **only its own** session and never a
408    /// spell-checker or find layer another caller added via `add_syntax_session`.
409    shim: Option<SessionId>,
410}
411
412impl HighlightRegistry {
413    /// Mint the next session id.
414    fn alloc_id(&mut self) -> SessionId {
415        let id = SessionId(self.next_id);
416        self.next_id += 1;
417        id
418    }
419
420    /// Insert a session at its place in the `(priority, id)` order.
421    ///
422    /// Ids only ever increase, so appending within a priority band keeps the tie-break right;
423    /// the search finds the first session that outranks `priority` and inserts before it. One
424    /// insertion point for both session kinds is what keeps every downstream iteration —
425    /// span collection and the kind join alike — priority-ordered for free.
426    fn insert_session(
427        &mut self,
428        id: SessionId,
429        priority: i32,
430        visibility: SessionVisibility,
431        body: SessionBody,
432    ) {
433        let at = self
434            .sessions
435            .iter()
436            .position(|s| s.priority > priority)
437            .unwrap_or(self.sessions.len());
438        self.sessions.insert(
439            at,
440            Session {
441                id,
442                priority,
443                visibility,
444                body,
445            },
446        );
447    }
448
449    /// Register a syntax session (empty cache; the caller rehighlights).
450    pub(crate) fn add_syntax(
451        &mut self,
452        highlighter: Arc<dyn SyntaxHighlighter>,
453        priority: i32,
454    ) -> SessionId {
455        let id = self.alloc_id();
456        self.insert_session(
457            id,
458            priority,
459            SessionVisibility::Shared,
460            SessionBody::Syntax(SyntaxSession {
461                highlighter,
462                blocks: HashMap::new(),
463            }),
464        );
465        id
466    }
467
468    /// Register an empty range session.
469    pub(crate) fn add_range(&mut self, priority: i32, visibility: SessionVisibility) -> SessionId {
470        let id = self.alloc_id();
471        self.insert_session(
472            id,
473            priority,
474            visibility,
475            SessionBody::Range(RangeSession {
476                ranges: Vec::new(),
477                block_index: HashMap::new(),
478                kind: HighlighterKind::None,
479            }),
480        );
481        id
482    }
483
484    /// Replace a range session's ranges, building its per-block index and cached kind from the
485    /// document's `block_positions` (each `(block_id, absolute_char_start)`, sorted by start).
486    ///
487    /// Returns the **extent that changed** — `(position, length)` spanning both the ranges that
488    /// went away and the ones that arrived — or `None` if `id` is not a range session (or does
489    /// not exist); a caller handing ranges to a syntax session is a bug, not a silent no-op to
490    /// swallow. `Some((_, 0))` means nothing at all changed.
491    ///
492    /// The extent is what lets a live view recolor just the affected block instead of
493    /// re-snapshotting the whole document on every push. It is the **union of the two sets**
494    /// rather than a precise diff: computing it costs one pass over vectors this function
495    /// already walks, and a caret-driven layer's before/after ranges are adjacent anyway.
496    pub(crate) fn set_ranges(
497        &mut self,
498        id: SessionId,
499        ranges: Vec<RangeHighlight>,
500        block_positions: &[(u64, usize)],
501    ) -> Option<(usize, usize)> {
502        for s in &mut self.sessions {
503            if s.id == id {
504                let SessionBody::Range(r) = &mut s.body else {
505                    return None;
506                };
507                let extent = changed_extent(&r.ranges, &ranges);
508                r.kind = compute_range_kind(&ranges);
509                r.block_index = build_block_index(&ranges, block_positions);
510                r.ranges = ranges;
511                return Some(extent);
512            }
513        }
514        None
515    }
516
517    /// Retire a session. Returns whether it existed.
518    pub(crate) fn remove(&mut self, id: SessionId) -> bool {
519        let before = self.sessions.len();
520        self.sessions.retain(|s| s.id != id);
521        self.sessions.len() != before
522    }
523
524    /// Install / replace / clear the classic single-highlighter shim
525    /// (`set_syntax_highlighter`). Replaces **only** the shim's own session — a spell-checker
526    /// or any other layer registered independently via [`add_syntax`](Self::add_syntax) is left
527    /// untouched. `None` clears the shim.
528    pub(crate) fn set_shim(&mut self, highlighter: Option<Arc<dyn SyntaxHighlighter>>) {
529        if let Some(id) = self.shim.take() {
530            self.remove(id);
531        }
532        if let Some(hl) = highlighter {
533            // The classic single-highlighter shim keeps the default band, so installing one
534            // behaves exactly as it did before priorities existed.
535            self.shim = Some(self.add_syntax(hl, 0));
536        }
537    }
538
539    /// Whether any session is attached.
540    pub(crate) fn is_empty(&self) -> bool {
541        self.sessions.is_empty()
542    }
543}
544
545/// Classification of the active highlighter's output.
546///
547/// Drives whether highlights are merged into the shaping input
548/// (`fragments`) or kept as a separate post-shape recolor overlay.
549#[derive(Debug, Clone, Copy, PartialEq, Eq)]
550pub(crate) enum HighlighterKind {
551    /// No highlighter attached.
552    None,
553    /// Every span touches only paint attributes (colors, underline
554    /// style, underline/overline/strikeout, tooltip). Glyph metrics are
555    /// unchanged, so the layout engine can recolor without reshaping —
556    /// `fragments` stay base and the spans ride as a paint overlay.
557    PaintOnly,
558    /// At least one span touches a metric-affecting field. Highlights
559    /// are merged into `fragments` (reshape required on change).
560    Metric,
561}
562
563/// Returns `true` if this format sets a metric-affecting field, i.e. one that changes glyph
564/// advances or line height: font family / size / weight / bold / italic, letter / word
565/// spacing, or vertical alignment (sub/superscript). The color and underline-decoration fields
566/// are paint-only and never trigger `true`.
567pub(crate) fn format_touches_metrics(f: &HighlightFormat) -> bool {
568    f.font_family.is_some()
569        || f.font_point_size.is_some()
570        || f.font_weight.is_some()
571        || f.font_bold.is_some()
572        || f.font_italic.is_some()
573        || f.letter_spacing.is_some()
574        || f.word_spacing.is_some()
575        || f.vertical_alignment.is_some()
576}
577
578/// Returns `true` if any span sets a metric-affecting field. See [`format_touches_metrics`].
579pub(crate) fn spans_touch_metrics(spans: &[HighlightSpan]) -> bool {
580    spans.iter().any(|s| format_touches_metrics(&s.format))
581}
582
583impl HighlighterKind {
584    /// None < PaintOnly < Metric. The join over a view's admitted sessions is the max: one
585    /// metric-affecting session forces the reshape path for the whole snapshot.
586    fn rank(self) -> u8 {
587        match self {
588            HighlighterKind::None => 0,
589            HighlighterKind::PaintOnly => 1,
590            HighlighterKind::Metric => 2,
591        }
592    }
593
594    fn join(self, other: HighlighterKind) -> HighlighterKind {
595        if other.rank() > self.rank() {
596            other
597        } else {
598            self
599        }
600    }
601}
602
603/// The kind of one syntax session, from its cached spans.
604fn syntax_session_kind(s: &SyntaxSession) -> HighlighterKind {
605    let mut any = false;
606    for bd in s.blocks.values() {
607        if spans_touch_metrics(&bd.spans) {
608            return HighlighterKind::Metric;
609        }
610        any |= !bd.spans.is_empty();
611    }
612    if any {
613        HighlighterKind::PaintOnly
614    } else {
615        HighlighterKind::None
616    }
617}
618
619/// The span of document affected by replacing `old` with `new`, as `(position, length)`.
620///
621/// Ranges identical in both sets contribute nothing — that is what makes a caret-driven layer
622/// cheap: re-pushing an unchanged set reports a zero length, and a caret that moved one
623/// sentence reports only the two sentences involved.
624///
625/// A zero length means "nothing changed", never "the whole document" — an empty-to-empty push
626/// really does affect nothing.
627///
628/// **Linear, by design.** This runs inside every `set_ranges`, and a spell-checked scene pushes
629/// tens of thousands of ranges on each edit, so comparing the sets by membership (quadratic)
630/// would cost more than the snapshot it exists to avoid — `range_index_perf` pins exactly that.
631/// Instead it skips the identical head and tail element-wise and covers only the window
632/// between them. Every producer builds its ranges in document order, so that window is tight;
633/// were one ever to reorder them, the result would merely be *wider* than necessary, which
634/// costs a larger recolor and never a wrong one.
635fn changed_extent(old: &[RangeHighlight], new: &[RangeHighlight]) -> (usize, usize) {
636    let common = old.len().min(new.len());
637
638    let mut head = 0;
639    while head < common && old[head] == new[head] {
640        head += 1;
641    }
642    if head == old.len() && head == new.len() {
643        return (0, 0);
644    }
645    let mut tail = 0;
646    while tail < common - head && old[old.len() - 1 - tail] == new[new.len() - 1 - tail] {
647        tail += 1;
648    }
649
650    let (mut lo, mut hi) = (usize::MAX, 0usize);
651    for r in old[head..old.len() - tail]
652        .iter()
653        .chain(&new[head..new.len() - tail])
654    {
655        lo = lo.min(r.start);
656        hi = hi.max(r.start + r.length);
657    }
658    if lo == usize::MAX {
659        (0, 0)
660    } else {
661        (lo, hi - lo)
662    }
663}
664
665/// The kind a set of ranges implies, from their formats. Computed **once** at
666/// [`set_ranges`](HighlightRegistry::set_ranges) time and cached on the [`RangeSession`], so
667/// [`effective_kind`](HighlightRegistry::effective_kind) never rescans the whole vector.
668fn compute_range_kind(ranges: &[RangeHighlight]) -> HighlighterKind {
669    let mut any = false;
670    for r in ranges {
671        if format_touches_metrics(&r.format) {
672            return HighlighterKind::Metric;
673        }
674        any |= r.length > 0;
675    }
676    if any {
677        HighlighterKind::PaintOnly
678    } else {
679        HighlighterKind::None
680    }
681}
682
683/// Bucket each range into every block it overlaps, from the document's block layout at push
684/// time (`block_positions` = each `(block_id, absolute_char_start)`, **sorted by start**).
685///
686/// A block spans `[start_i, start_{i+1})` in absolute char space (the last runs to `MAX`); a
687/// range is bucketed into a block when their half-open spans intersect. Almost always that is a
688/// single block — the spell/find producers emit ranges within one paragraph — but a range that
689/// happens to straddle a boundary is added to **every** block it touches, so the per-block clip
690/// downstream sees it in each, exactly as the old full scan did.
691fn build_block_index(
692    ranges: &[RangeHighlight],
693    block_positions: &[(u64, usize)],
694) -> HashMap<usize, Vec<u32>> {
695    let mut index: HashMap<usize, Vec<u32>> = HashMap::new();
696    if block_positions.is_empty() {
697        return index;
698    }
699    for (ri, r) in ranges.iter().enumerate() {
700        let r_end = r.start.saturating_add(r.length); // exclusive
701        // The block containing `r.start`: the last block whose start <= r.start.
702        let mut bi = match block_positions.binary_search_by_key(&r.start, |&(_, p)| p) {
703            Ok(i) => i,
704            Err(i) => i.saturating_sub(1),
705        };
706        // Walk forward over every block the range still overlaps.
707        while bi < block_positions.len() {
708            let b_start = block_positions[bi].1;
709            if b_start >= r_end {
710                break; // this block (and all later ones) start past the range
711            }
712            let b_end = block_positions
713                .get(bi + 1)
714                .map(|&(_, p)| p)
715                .unwrap_or(usize::MAX);
716            // Half-open intersection [b_start, b_end) ∩ [r.start, r_end).
717            if r.start < b_end && b_start < r_end {
718                index
719                    .entry(block_positions[bi].0 as usize)
720                    .or_default()
721                    .push(ri as u32);
722            }
723            bi += 1;
724        }
725    }
726    index
727}
728
729impl HighlightRegistry {
730    /// The effective [`HighlighterKind`](enum@HighlighterKind) for a view — the join over the
731    /// sessions the mask admits. Computed **once at the snapshot root** and threaded down as a
732    /// plain value, exactly like the old single document-wide kind; a view showing only
733    /// paint-only sessions never pays the reshape path for a metric session it does not show.
734    pub(crate) fn effective_kind(&self, mask: &HighlightMask) -> HighlighterKind {
735        if mask.is_empty() {
736            return HighlighterKind::None;
737        }
738        let mut kind = HighlighterKind::None;
739        for s in &self.sessions {
740            if !mask.admits(s.id, s.visibility) {
741                continue;
742            }
743            let k = match &s.body {
744                SessionBody::Syntax(syn) => syntax_session_kind(syn),
745                SessionBody::Range(r) => r.kind, // cached at set_ranges — no rescan
746            };
747            kind = kind.join(k);
748            if kind == HighlighterKind::Metric {
749                break;
750            }
751        }
752        kind
753    }
754
755    /// The join over **every** session, mask and visibility ignored. See
756    /// [`TextDocumentInner::recompute_highlight_kind`](crate::inner::TextDocumentInner), the
757    /// only caller.
758    pub(crate) fn kind_of_every_session(&self) -> HighlighterKind {
759        let mut kind = HighlighterKind::None;
760        for s in &self.sessions {
761            let k = match &s.body {
762                SessionBody::Syntax(syn) => syntax_session_kind(syn),
763                SessionBody::Range(r) => r.kind,
764            };
765            kind = kind.join(k);
766            if kind == HighlighterKind::Metric {
767                break;
768            }
769        }
770        kind
771    }
772}
773
774// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
775// Merge algorithm
776// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
777
778/// Apply highlight format overrides onto a base `TextFormat`.
779fn apply_highlight(base: &TextFormat, hl: &HighlightFormat) -> TextFormat {
780    TextFormat {
781        font_family: hl.font_family.clone().or_else(|| base.font_family.clone()),
782        font_point_size: hl.font_point_size.or(base.font_point_size),
783        font_weight: hl.font_weight.or(base.font_weight),
784        font_bold: hl.font_bold.or(base.font_bold),
785        font_italic: hl.font_italic.or(base.font_italic),
786        font_underline: hl.font_underline.or(base.font_underline),
787        font_overline: hl.font_overline.or(base.font_overline),
788        font_strikeout: hl.font_strikeout.or(base.font_strikeout),
789        letter_spacing: hl.letter_spacing.or(base.letter_spacing),
790        word_spacing: hl.word_spacing.or(base.word_spacing),
791        underline_style: hl
792            .underline_style
793            .clone()
794            .or_else(|| base.underline_style.clone()),
795        vertical_alignment: hl
796            .vertical_alignment
797            .clone()
798            .or_else(|| base.vertical_alignment.clone()),
799        tooltip: hl.tooltip.clone().or_else(|| base.tooltip.clone()),
800        foreground_color: hl.foreground_color.or(base.foreground_color),
801        background_color: hl.background_color.or(base.background_color),
802        underline_color: hl.underline_color.or(base.underline_color),
803        // Anchors are not overridden by highlights.
804        anchor_href: base.anchor_href.clone(),
805        anchor_names: base.anchor_names.clone(),
806        is_anchor: base.is_anchor,
807        // A highlight paints over a format; it never edits one, and removing a
808        // link is an edit.
809        clear_link: false,
810    }
811}
812
813/// Merge a set of overlapping highlights into a single `HighlightFormat`.
814/// Later spans override earlier spans for the same field.
815fn merge_overlapping_highlights(spans: &[&HighlightSpan]) -> HighlightFormat {
816    let mut merged = HighlightFormat::default();
817    for span in spans {
818        let f = &span.format;
819        if f.foreground_color.is_some() {
820            merged.foreground_color = f.foreground_color;
821        }
822        if f.background_color.is_some() {
823            merged.background_color = f.background_color;
824        }
825        if f.underline_color.is_some() {
826            merged.underline_color = f.underline_color;
827        }
828        if f.font_family.is_some() {
829            merged.font_family = f.font_family.clone();
830        }
831        if f.font_point_size.is_some() {
832            merged.font_point_size = f.font_point_size;
833        }
834        if f.font_weight.is_some() {
835            merged.font_weight = f.font_weight;
836        }
837        if f.font_bold.is_some() {
838            merged.font_bold = f.font_bold;
839        }
840        if f.font_italic.is_some() {
841            merged.font_italic = f.font_italic;
842        }
843        if f.font_underline.is_some() {
844            merged.font_underline = f.font_underline;
845        }
846        if f.font_overline.is_some() {
847            merged.font_overline = f.font_overline;
848        }
849        if f.font_strikeout.is_some() {
850            merged.font_strikeout = f.font_strikeout;
851        }
852        if f.letter_spacing.is_some() {
853            merged.letter_spacing = f.letter_spacing;
854        }
855        if f.word_spacing.is_some() {
856            merged.word_spacing = f.word_spacing;
857        }
858        if f.underline_style.is_some() {
859            merged.underline_style = f.underline_style.clone();
860        }
861        if f.vertical_alignment.is_some() {
862            merged.vertical_alignment = f.vertical_alignment.clone();
863        }
864        if f.tooltip.is_some() {
865            merged.tooltip = f.tooltip.clone();
866        }
867    }
868    merged
869}
870
871/// Flatten a block's stored highlight spans into a list of
872/// [`PaintHighlightSpan`](crate::flow::PaintHighlightSpan)s for the
873/// paint-overlay path.
874///
875/// Only called when the active highlighter is [`HighlighterKind::PaintOnly`],
876/// so metric fields are guaranteed absent and ignored here. Overlapping
877/// spans are resolved exactly like `merge_highlight_spans` (split at every
878/// boundary, last-wins per field) so the overlay matches what the merged
879/// path would have produced. `block_len` is the block's character length.
880/// Sub-ranges with no paint field set are skipped.
881pub(crate) fn extract_paint_spans(
882    spans: &[HighlightSpan],
883    block_len: usize,
884) -> Vec<crate::flow::PaintHighlightSpan> {
885    if spans.is_empty() || block_len == 0 {
886        return Vec::new();
887    }
888
889    // Collect and dedupe all span boundaries within (0, block_len).
890    let mut boundaries = vec![0usize, block_len];
891    for s in spans {
892        let end = s.start.saturating_add(s.length);
893        if s.start > 0 && s.start < block_len {
894            boundaries.push(s.start);
895        }
896        if end > 0 && end < block_len {
897            boundaries.push(end);
898        }
899    }
900    boundaries.sort_unstable();
901    boundaries.dedup();
902
903    // Sweep the boundaries left→right, maintaining the set of spans active in the
904    // current window in ORIGINAL-INDEX order (a `BTreeSet` of indices). This
905    // replaces the former O(boundaries × spans) rescan — which re-filtered every
906    // span at every boundary and went quadratic on a block carrying thousands of
907    // ranges (a spell-checked Lorem paragraph, where every window still walked all
908    // ~m ranges) — with O(m log m + Σ|active|). The emitted spans are byte-identical:
909    // `BTreeSet` iterates indices ascending, the same order the old
910    // `spans.iter().filter()` produced, so `merge_overlapping_highlights` sees the
911    // same (last-wins) sequence. Each span is inserted and removed exactly once as
912    // the two monotonic pointers advance.
913    let n = spans.len();
914    let mut by_start: Vec<usize> = (0..n).collect();
915    by_start.sort_by_key(|&i| spans[i].start);
916    let mut by_end: Vec<usize> = (0..n).collect();
917    by_end.sort_by_key(|&i| spans[i].start.saturating_add(spans[i].length));
918
919    let mut active: std::collections::BTreeSet<usize> = std::collections::BTreeSet::new();
920    let mut ps = 0usize; // next span to activate (ordered by start)
921    let mut pe = 0usize; // next span to deactivate (ordered by end)
922    let mut scratch: Vec<&HighlightSpan> = Vec::new();
923
924    let mut result = Vec::new();
925    for w in boundaries.windows(2) {
926        let (sub_start, sub_end) = (w[0], w[1]);
927        if sub_end <= sub_start {
928            continue;
929        }
930        // A span is active in [sub_start, sub_end) iff start <= sub_start < end.
931        // Activate every span that has started by the left edge, then deactivate
932        // every span that has ended by it — add-before-remove so a zero-length
933        // span (start == end == sub_start) is excluded, matching the old strict
934        // `end > sub_start` test.
935        while ps < n && spans[by_start[ps]].start <= sub_start {
936            active.insert(by_start[ps]);
937            ps += 1;
938        }
939        while pe < n
940            && spans[by_end[pe]]
941                .start
942                .saturating_add(spans[by_end[pe]].length)
943                <= sub_start
944        {
945            active.remove(&by_end[pe]);
946            pe += 1;
947        }
948        if active.is_empty() {
949            continue;
950        }
951        scratch.clear();
952        scratch.extend(active.iter().map(|&i| &spans[i]));
953        let merged = merge_overlapping_highlights(&scratch);
954        if merged.foreground_color.is_none()
955            && merged.background_color.is_none()
956            && merged.underline_color.is_none()
957            && merged.underline_style.is_none()
958            && merged.font_underline.is_none()
959            && merged.font_overline.is_none()
960            && merged.font_strikeout.is_none()
961        {
962            continue;
963        }
964        result.push(crate::flow::PaintHighlightSpan {
965            start: sub_start,
966            length: sub_end - sub_start,
967            foreground_color: merged.foreground_color,
968            background_color: merged.background_color,
969            underline_color: merged.underline_color,
970            underline_style: merged.underline_style,
971            font_underline: merged.font_underline,
972            font_overline: merged.font_overline,
973            font_strikeout: merged.font_strikeout,
974        });
975    }
976    result
977}
978
979/// Merge highlight spans into a list of fragments.
980///
981/// Text fragments that overlap with highlight spans are split at span
982/// boundaries. The highlight format is overlaid onto the base `TextFormat`.
983/// Image fragments receive the overlay without splitting.
984/// Local copy of the word-start computation from `text_block.rs`:
985/// returns character indices (not byte offsets) where a Unicode word
986/// starts, per UAX #29. Mirrors the upstream helper so highlight
987/// splits produce accessibility-correct word_starts for each
988/// sub-fragment without reaching into `text_block`.
989fn compute_word_starts_local(text: &str) -> Vec<u8> {
990    use unicode_segmentation::UnicodeSegmentation;
991    let mut result = Vec::new();
992    let mut byte_to_char: Vec<(usize, usize)> = Vec::new();
993    for (ci, (bi, _)) in text.char_indices().enumerate() {
994        byte_to_char.push((bi, ci));
995    }
996    for (byte_off, _word) in text.unicode_word_indices() {
997        let char_idx = byte_to_char
998            .iter()
999            .find(|(bi, _)| *bi == byte_off)
1000            .map(|(_, ci)| *ci)
1001            .unwrap_or(0);
1002        if let Ok(idx) = u8::try_from(char_idx) {
1003            result.push(idx);
1004        } else {
1005            break;
1006        }
1007    }
1008    result
1009}
1010
1011pub(crate) fn merge_highlight_spans(
1012    fragments: Vec<FragmentContent>,
1013    spans: &[HighlightSpan],
1014) -> Vec<FragmentContent> {
1015    if spans.is_empty() {
1016        return fragments;
1017    }
1018
1019    let mut result = Vec::with_capacity(fragments.len());
1020
1021    for frag in fragments {
1022        match frag {
1023            FragmentContent::Text {
1024                ref text,
1025                ref format,
1026                offset,
1027                length,
1028                element_id,
1029                word_starts: _,
1030            } => {
1031                let frag_end = offset + length;
1032
1033                // Collect highlight boundaries within this fragment's range.
1034                let mut boundaries = Vec::new();
1035                boundaries.push(offset);
1036                boundaries.push(frag_end);
1037
1038                for span in spans {
1039                    let span_end = span.start + span.length;
1040                    // Does this span overlap the fragment?
1041                    if span.start < frag_end && span_end > offset {
1042                        if span.start > offset && span.start < frag_end {
1043                            boundaries.push(span.start);
1044                        }
1045                        if span_end > offset && span_end < frag_end {
1046                            boundaries.push(span_end);
1047                        }
1048                    }
1049                }
1050
1051                boundaries.sort_unstable();
1052                boundaries.dedup();
1053
1054                // Split the text at each boundary and apply overlapping highlights.
1055                let chars: Vec<char> = text.chars().collect();
1056                for window in boundaries.windows(2) {
1057                    let sub_start = window[0];
1058                    let sub_end = window[1];
1059                    let sub_len = sub_end - sub_start;
1060                    if sub_len == 0 {
1061                        continue;
1062                    }
1063
1064                    // Collect all highlight spans overlapping [sub_start, sub_end).
1065                    let active: Vec<&HighlightSpan> = spans
1066                        .iter()
1067                        .filter(|s| {
1068                            let s_end = s.start + s.length;
1069                            s.start < sub_end && s_end > sub_start
1070                        })
1071                        .collect();
1072
1073                    let char_start = sub_start - offset;
1074                    let char_end = char_start + sub_len;
1075                    let sub_text: String = chars[char_start..char_end].iter().collect();
1076
1077                    let sub_format = if active.is_empty() {
1078                        format.clone()
1079                    } else {
1080                        let merged_hl = merge_overlapping_highlights(&active);
1081                        apply_highlight(format, &merged_hl)
1082                    };
1083
1084                    let sub_word_starts = compute_word_starts_local(&sub_text);
1085                    result.push(FragmentContent::Text {
1086                        text: sub_text,
1087                        format: sub_format,
1088                        offset: sub_start,
1089                        length: sub_len,
1090                        // All sub-fragments split from one source
1091                        // `FragmentContent::Text` reference the same
1092                        // underlying format run — only the highlight
1093                        // formatting differs. Sharing the id is
1094                        // correct for accessibility (the underlying
1095                        // text belongs to one stable run) at the cost
1096                        // that synthetic NodeIds for highlighted
1097                        // sub-runs collide unless the caller further
1098                        // disambiguates.
1099                        // The teksilo-widgets layer handles that by
1100                        // mixing the `offset` into the synthetic-id
1101                        // hash alongside `element_id`.
1102                        element_id,
1103                        word_starts: sub_word_starts,
1104                    });
1105                }
1106            }
1107            FragmentContent::Image {
1108                ref name,
1109                ref alt,
1110                width,
1111                height,
1112                quality,
1113                ref format,
1114                offset,
1115                element_id,
1116            } => {
1117                // Find overlapping highlights for this single-char position.
1118                let active: Vec<&HighlightSpan> = spans
1119                    .iter()
1120                    .filter(|s| {
1121                        let s_end = s.start + s.length;
1122                        s.start < offset + 1 && s_end > offset
1123                    })
1124                    .collect();
1125
1126                let img_format = if active.is_empty() {
1127                    format.clone()
1128                } else {
1129                    let merged_hl = merge_overlapping_highlights(&active);
1130                    apply_highlight(format, &merged_hl)
1131                };
1132
1133                result.push(FragmentContent::Image {
1134                    name: name.clone(),
1135                    alt: alt.clone(),
1136                    width,
1137                    height,
1138                    quality,
1139                    format: img_format,
1140                    offset,
1141                    element_id,
1142                });
1143            }
1144            // A reference is a single-character position too, so it takes a
1145            // highlight the same way an image does — a find hit or a spell
1146            // range covering it must recolour the marker, not skip it.
1147            FragmentContent::FootnoteReference {
1148                ref label,
1149                ref marker,
1150                ref format,
1151                offset,
1152                element_id,
1153            } => {
1154                let active: Vec<&HighlightSpan> = spans
1155                    .iter()
1156                    .filter(|s| {
1157                        let s_end = s.start + s.length;
1158                        s.start < offset + 1 && s_end > offset
1159                    })
1160                    .collect();
1161
1162                let note_format = if active.is_empty() {
1163                    format.clone()
1164                } else {
1165                    let merged_hl = merge_overlapping_highlights(&active);
1166                    apply_highlight(format, &merged_hl)
1167                };
1168
1169                result.push(FragmentContent::FootnoteReference {
1170                    label: label.clone(),
1171                    marker: marker.clone(),
1172                    format: note_format,
1173                    offset,
1174                    element_id,
1175                });
1176            }
1177        }
1178    }
1179
1180    result
1181}
1182
1183// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1184// Re-highlighting
1185// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1186
1187/// Every block's id + text, sorted by `document_position`. The text materialization is the
1188/// expensive part (rope → String for the whole document), so callers that only need block
1189/// *positions* use [`ordered_block_positions`] instead.
1190fn ordered_block_ids(inner: &TextDocumentInner) -> Vec<(u64, String)> {
1191    let mut blocks = block_commands::get_all_block(&inner.ctx).unwrap_or_default();
1192    let store = inner.ctx.db_context.get_store();
1193    crate::inner::refresh_block_positions(&mut blocks, store);
1194    blocks.sort_by_key(|b| b.document_position);
1195    blocks
1196        .into_iter()
1197        .map(|b| {
1198            let entity: common::entities::Block = b.clone().into();
1199            let text = common::database::rope_helpers::block_content_via_store(&entity, store);
1200            (b.id, text)
1201        })
1202        .collect()
1203}
1204
1205/// Every block's id + absolute char start (`document_position`), sorted — **without**
1206/// materializing any block text. This is the cheap sibling of [`ordered_block_ids`]; the
1207/// double full-document scan it exists to prevent is described on [`TextDocumentInner::rehighlight_affected`].
1208pub(crate) fn ordered_block_positions(inner: &TextDocumentInner) -> Vec<(u64, usize)> {
1209    let mut blocks = block_commands::get_all_block(&inner.ctx).unwrap_or_default();
1210    let store = inner.ctx.db_context.get_store();
1211    crate::inner::refresh_block_positions(&mut blocks, store);
1212    blocks.sort_by_key(|b| b.document_position);
1213    blocks
1214        .into_iter()
1215        .map(|b| (b.id, b.document_position.max(0) as usize))
1216        .collect()
1217}
1218
1219/// A block's absolute char start and char length — the geometry a range session needs to
1220/// slice its absolute ranges to this block. `document_position` is the block's absolute char
1221/// start, exactly as the find/replace path resolves offsets against it.
1222fn block_geometry(inner: &TextDocumentInner, block_id: usize) -> (usize, usize) {
1223    let store = inner.ctx.db_context.get_store();
1224    let Some(mut dto) = block_commands::get_block(&inner.ctx, &(block_id as u64))
1225        .ok()
1226        .flatten()
1227    else {
1228        return (0, 0);
1229    };
1230    crate::inner::refresh_block_position(&mut dto, store);
1231    let abs_start = dto.document_position.max(0) as usize;
1232    let entity: common::entities::Block = dto.into();
1233    let len = common::database::rope_helpers::block_char_length(&entity, store).max(0) as usize;
1234    (abs_start, len)
1235}
1236
1237/// The block-relative highlight spans a **view** sees for one block: every session the mask
1238/// admits, in registration order (so a later session's field overrides an earlier one), with
1239/// range sessions' absolute ranges sliced to this block.
1240///
1241/// The block geometry needed to slice range sessions is fetched **lazily** — a document with
1242/// only syntax sessions (or a mask that admits none) pays nothing for it.
1243pub(crate) fn merged_spans_for_block(
1244    inner: &TextDocumentInner,
1245    block_id: usize,
1246    mask: &HighlightMask,
1247) -> Vec<HighlightSpan> {
1248    if mask.is_empty() || inner.highlights.is_empty() {
1249        return Vec::new();
1250    }
1251
1252    let mut geom: Option<(usize, usize)> = None;
1253    let mut out: Vec<HighlightSpan> = Vec::new();
1254
1255    for s in &inner.highlights.sessions {
1256        if !mask.admits(s.id, s.visibility) {
1257            continue;
1258        }
1259        match &s.body {
1260            SessionBody::Syntax(syn) => {
1261                if let Some(bd) = syn.blocks.get(&block_id) {
1262                    out.extend(bd.spans.iter().cloned());
1263                }
1264            }
1265            SessionBody::Range(r) => {
1266                // Only the ranges the index says touch this block — O(ranges-in-block), not
1267                // O(all-ranges). A block absent from the index (empty bucket) contributes
1268                // nothing; see [`RangeSession::block_index`] on the missing-vs-stale window.
1269                let Some(indices) = r.block_index.get(&block_id) else {
1270                    continue;
1271                };
1272                let (abs_start, len) = *geom.get_or_insert_with(|| block_geometry(inner, block_id));
1273                let block_end = abs_start + len;
1274                for &ri in indices {
1275                    let rng = &r.ranges[ri as usize];
1276                    // Saturating: a range session's offsets come from the host (an externally
1277                    // driven spell-checker included), so a wild `start + length` must clamp, not
1278                    // overflow-panic in debug / wrap in release.
1279                    let lo = rng.start.max(abs_start);
1280                    let hi = rng.start.saturating_add(rng.length).min(block_end);
1281                    if lo < hi {
1282                        out.push(HighlightSpan {
1283                            start: lo - abs_start,
1284                            length: hi - lo,
1285                            format: rng.format.clone(),
1286                        });
1287                    }
1288                }
1289            }
1290        }
1291    }
1292    out
1293}
1294
1295impl TextDocumentInner {
1296    /// Re-highlight every block, for every **syntax** session (range sessions carry their
1297    /// ranges directly and never run a callback).
1298    ///
1299    /// Each syntax session runs its **own** cascade — its own `previous_state` timeline,
1300    /// reset to −1 here, and its own per-block cache. State never crosses between sessions, or
1301    /// one highlighter's multiline-comment run would corrupt another's. The block text is
1302    /// materialized **once** and shared across sessions, though: the rope → String scan is the
1303    /// cost, and running it per session would be N× for no reason.
1304    pub(crate) fn rehighlight_all(&mut self) {
1305        if self.highlights.is_empty() {
1306            self.recompute_highlight_kind();
1307            return;
1308        }
1309        let blocks = ordered_block_ids(self);
1310
1311        for si in 0..self.highlights.sessions.len() {
1312            let SessionBody::Syntax(syn) = &self.highlights.sessions[si].body else {
1313                continue;
1314            };
1315            let highlighter = Arc::clone(&syn.highlighter);
1316
1317            let mut fresh: HashMap<usize, BlockHighlightData> = HashMap::new();
1318            let mut previous_state: i64 = -1;
1319            for (block_id, text) in &blocks {
1320                let bid = *block_id as usize;
1321                let mut ctx = HighlightContext::new(bid, previous_state, None);
1322                highlighter.highlight_block(text, &mut ctx);
1323                let (spans, state, user_data) = ctx.into_parts();
1324                previous_state = state;
1325                fresh.insert(
1326                    bid,
1327                    BlockHighlightData {
1328                        spans,
1329                        state,
1330                        user_data,
1331                    },
1332                );
1333            }
1334            if let SessionBody::Syntax(syn) = &mut self.highlights.sessions[si].body {
1335                syn.blocks = fresh;
1336            }
1337        }
1338
1339        self.recompute_highlight_kind();
1340    }
1341
1342    /// Recompute the cached document-wide [`highlight_kind`](TextDocumentInner::highlight_kind)
1343    /// (the join over **every** session, whatever its visibility). Masked snapshots derive
1344    /// their own kind at the root.
1345    ///
1346    /// Every session and not [`HighlightMask::all`]'s set, because this value does not decide
1347    /// what a view *draws*. It decides which event a highlight change is announced with
1348    /// (`HighlightPaintChanged` against `FormatChanged`), and `queue_highlight_changed`
1349    /// returns early when the kind is `None` on both sides. Leaving
1350    /// [`OptIn`](SessionVisibility::OptIn) sessions out would make a document whose only
1351    /// layer is one of them announce nothing at all, so the one view that *had* asked for it
1352    /// would never hear that its marks moved.
1353    pub(crate) fn recompute_highlight_kind(&mut self) {
1354        self.highlight_kind = self.highlights.kind_of_every_session();
1355    }
1356
1357    /// This syntax session's cached block state (−1 if unset / not a syntax session).
1358    fn syntax_block_state(&self, session_idx: usize, block_id: usize) -> i64 {
1359        match &self.highlights.sessions[session_idx].body {
1360            SessionBody::Syntax(s) => s.blocks.get(&block_id).map_or(-1, |d| d.state),
1361            _ => -1,
1362        }
1363    }
1364
1365    /// Re-highlight from a block for every syntax session, cascading each until its own state
1366    /// stabilizes.
1367    pub(crate) fn rehighlight_from_block(&mut self, start_block_id: usize) {
1368        if self.highlights.is_empty() {
1369            return;
1370        }
1371        let blocks = ordered_block_ids(self);
1372        let Some(start_idx) = blocks
1373            .iter()
1374            .position(|(id, _)| *id as usize == start_block_id)
1375        else {
1376            return;
1377        };
1378
1379        for si in 0..self.highlights.sessions.len() {
1380            if matches!(self.highlights.sessions[si].body, SessionBody::Syntax(_)) {
1381                self.rehighlight_session_from(si, start_idx, &blocks);
1382            }
1383        }
1384
1385        self.recompute_highlight_kind();
1386    }
1387
1388    /// One syntax session's cascade from `start_idx` (see [`Self::rehighlight_from_block`]).
1389    fn rehighlight_session_from(
1390        &mut self,
1391        session_idx: usize,
1392        start_idx: usize,
1393        blocks: &[(u64, String)],
1394    ) {
1395        let highlighter = match &self.highlights.sessions[session_idx].body {
1396            SessionBody::Syntax(s) => Arc::clone(&s.highlighter),
1397            _ => return,
1398        };
1399
1400        for i in start_idx..blocks.len() {
1401            let (block_id, ref text) = blocks[i];
1402            let bid = block_id as usize;
1403
1404            let previous_state = if i == 0 {
1405                -1
1406            } else {
1407                self.syntax_block_state(session_idx, blocks[i - 1].0 as usize)
1408            };
1409
1410            // Reuse the block's existing user data (this session's own).
1411            let user_data = match &mut self.highlights.sessions[session_idx].body {
1412                SessionBody::Syntax(s) => s.blocks.get_mut(&bid).and_then(|d| d.user_data.take()),
1413                _ => None,
1414            };
1415            let old_state = self.syntax_block_state(session_idx, bid);
1416
1417            let mut ctx = HighlightContext::new(bid, previous_state, user_data);
1418            highlighter.highlight_block(text, &mut ctx);
1419            let (spans, state, user_data) = ctx.into_parts();
1420
1421            if let SessionBody::Syntax(s) = &mut self.highlights.sessions[session_idx].body {
1422                s.blocks.insert(
1423                    bid,
1424                    BlockHighlightData {
1425                        spans,
1426                        state,
1427                        user_data,
1428                    },
1429                );
1430            }
1431
1432            // Past the start and the state is unchanged: this session's cascade has settled.
1433            if i > start_idx && state == old_state {
1434                break;
1435            }
1436        }
1437    }
1438
1439    /// Re-highlight the blocks a content change at `position` affects.
1440    ///
1441    /// The target block is found from a **positions-only** scan (no block text materialized),
1442    /// then [`rehighlight_from_block`](Self::rehighlight_from_block) does the one text scan it
1443    /// needs. The old code materialized every block's text here *just to locate one block*, and
1444    /// `rehighlight_from_block` then materialized them all again — two full-document rope →
1445    /// String passes on every single keystroke, at N=1.
1446    pub(crate) fn rehighlight_affected(&mut self, position: usize) {
1447        if self.highlights.is_empty() {
1448            return;
1449        }
1450        let positions = ordered_block_positions(self);
1451        if positions.is_empty() {
1452            return;
1453        }
1454        // The last block whose start is at or before `position` contains it.
1455        let target_bid = positions
1456            .iter()
1457            .rev()
1458            .find(|(_, bp)| position >= *bp)
1459            .map(|(id, _)| *id as usize)
1460            .unwrap_or_else(|| positions[0].0 as usize);
1461
1462        self.rehighlight_from_block(target_bid);
1463    }
1464}
1465
1466#[cfg(test)]
1467mod paint_span_tests {
1468    use super::*;
1469
1470    /// The previous O(boundaries × spans) implementation, kept verbatim as the
1471    /// oracle the sweep must reproduce byte-for-byte.
1472    fn extract_paint_spans_reference(
1473        spans: &[HighlightSpan],
1474        block_len: usize,
1475    ) -> Vec<crate::flow::PaintHighlightSpan> {
1476        if spans.is_empty() || block_len == 0 {
1477            return Vec::new();
1478        }
1479        let mut boundaries = vec![0usize, block_len];
1480        for s in spans {
1481            let end = s.start.saturating_add(s.length);
1482            if s.start > 0 && s.start < block_len {
1483                boundaries.push(s.start);
1484            }
1485            if end > 0 && end < block_len {
1486                boundaries.push(end);
1487            }
1488        }
1489        boundaries.sort_unstable();
1490        boundaries.dedup();
1491        let mut result = Vec::new();
1492        for w in boundaries.windows(2) {
1493            let (sub_start, sub_end) = (w[0], w[1]);
1494            if sub_end <= sub_start {
1495                continue;
1496            }
1497            let active: Vec<&HighlightSpan> = spans
1498                .iter()
1499                .filter(|s| s.start < sub_end && s.start + s.length > sub_start)
1500                .collect();
1501            if active.is_empty() {
1502                continue;
1503            }
1504            let merged = merge_overlapping_highlights(&active);
1505            if merged.foreground_color.is_none()
1506                && merged.background_color.is_none()
1507                && merged.underline_color.is_none()
1508                && merged.underline_style.is_none()
1509                && merged.font_underline.is_none()
1510                && merged.font_overline.is_none()
1511                && merged.font_strikeout.is_none()
1512            {
1513                continue;
1514            }
1515            result.push(crate::flow::PaintHighlightSpan {
1516                start: sub_start,
1517                length: sub_end - sub_start,
1518                foreground_color: merged.foreground_color,
1519                background_color: merged.background_color,
1520                underline_color: merged.underline_color,
1521                underline_style: merged.underline_style,
1522                font_underline: merged.font_underline,
1523                font_overline: merged.font_overline,
1524                font_strikeout: merged.font_strikeout,
1525            });
1526        }
1527        result
1528    }
1529
1530    /// A span whose single paint field is keyed by `k`, so that last-wins merging
1531    /// across overlaps produces observably different output when the active-set
1532    /// ORDER is wrong — the property most at risk in the rewrite.
1533    fn span(start: usize, length: usize, k: usize) -> HighlightSpan {
1534        let c = |v: usize| crate::Color {
1535            red: v as u8,
1536            green: (v >> 8) as u8,
1537            blue: 7,
1538            alpha: 255,
1539        };
1540        let format = match k % 4 {
1541            0 => HighlightFormat {
1542                background_color: Some(c(k)),
1543                ..Default::default()
1544            },
1545            1 => HighlightFormat {
1546                foreground_color: Some(c(k)),
1547                ..Default::default()
1548            },
1549            2 => HighlightFormat {
1550                underline_color: Some(c(k)),
1551                ..Default::default()
1552            },
1553            // No paint field: must be dropped by both implementations.
1554            _ => HighlightFormat {
1555                font_bold: Some(true),
1556                ..Default::default()
1557            },
1558        };
1559        HighlightSpan {
1560            start,
1561            length,
1562            format,
1563        }
1564    }
1565
1566    #[test]
1567    fn sweep_matches_reference_on_edge_cases() {
1568        let cases: Vec<(Vec<HighlightSpan>, usize)> = vec![
1569            (vec![], 10),
1570            (vec![span(0, 4, 0)], 0),                  // empty block
1571            (vec![span(0, 5, 0)], 10),                 // single
1572            (vec![span(0, 3, 0), span(5, 3, 1)], 10),  // disjoint
1573            (vec![span(2, 5, 0), span(4, 5, 1)], 12),  // overlap: later wins in [4,7)
1574            (vec![span(0, 10, 0), span(3, 2, 1)], 10), // nested
1575            (vec![span(0, 3, 0), span(3, 3, 1)], 10),  // adjacent (touch, no overlap)
1576            (vec![span(4, 0, 0)], 10),                 // zero-length → nothing
1577            (vec![span(0, 4, 3)], 10),                 // no paint field → dropped
1578            (vec![span(8, 5, 0)], 10),                 // spills past block_len
1579            (vec![span(12, 3, 0)], 10),                // entirely past block_len
1580            (vec![span(0, 4, 0), span(0, 4, 1), span(0, 4, 2)], 10), // coincident, order matters
1581        ];
1582        for (i, (spans, len)) in cases.iter().enumerate() {
1583            assert_eq!(
1584                extract_paint_spans(spans, *len),
1585                extract_paint_spans_reference(spans, *len),
1586                "edge case {i}: spans={spans:?} block_len={len}"
1587            );
1588        }
1589    }
1590
1591    #[test]
1592    fn sweep_matches_reference_randomized() {
1593        // Deterministic LCG — no rng dependency, reproducible across runs.
1594        let mut state: u64 = 0x9E37_79B9_7F4A_7C15;
1595        let mut next = |bound: usize| -> usize {
1596            state = state
1597                .wrapping_mul(6364136223846793005)
1598                .wrapping_add(1442695040888963407);
1599            ((state >> 33) as usize) % bound.max(1)
1600        };
1601        for trial in 0..2000 {
1602            let block_len = 1 + next(40);
1603            let m = next(14); // 0..13 spans, a mix of overlap densities
1604            let mut spans = Vec::with_capacity(m);
1605            for k in 0..m {
1606                let start = next(block_len + 4); // sometimes at/past the end
1607                let length = next(block_len + 2); // sometimes zero, sometimes spilling over
1608                spans.push(span(start, length, trial + k));
1609            }
1610            assert_eq!(
1611                extract_paint_spans(&spans, block_len),
1612                extract_paint_spans_reference(&spans, block_len),
1613                "trial {trial}: spans={spans:?} block_len={block_len}"
1614            );
1615        }
1616    }
1617}
1618
1619#[cfg(test)]
1620mod index_tests {
1621    use super::*;
1622
1623    fn paint(start: usize, length: usize) -> RangeHighlight {
1624        RangeHighlight {
1625            start,
1626            length,
1627            format: HighlightFormat {
1628                background_color: Some(crate::Color {
1629                    red: 255,
1630                    green: 0,
1631                    blue: 0,
1632                    alpha: 255,
1633                }),
1634                ..Default::default()
1635            },
1636        }
1637    }
1638
1639    fn metric(start: usize, length: usize) -> RangeHighlight {
1640        RangeHighlight {
1641            start,
1642            length,
1643            format: HighlightFormat {
1644                font_bold: Some(true),
1645                ..Default::default()
1646            },
1647        }
1648    }
1649
1650    // ── compute_range_kind (B2-M2): the cached kind matches the old per-range classification ──
1651
1652    #[test]
1653    fn kind_is_none_when_nothing_paints() {
1654        assert_eq!(compute_range_kind(&[]), HighlighterKind::None);
1655        // A zero-length paint range colours nothing → None.
1656        assert_eq!(compute_range_kind(&[paint(3, 0)]), HighlighterKind::None);
1657    }
1658
1659    #[test]
1660    fn kind_is_paint_only_for_a_background_range() {
1661        assert_eq!(
1662            compute_range_kind(&[paint(0, 4)]),
1663            HighlighterKind::PaintOnly
1664        );
1665    }
1666
1667    #[test]
1668    fn kind_is_metric_when_any_range_touches_metrics() {
1669        // One metric range among paint ones lifts the whole session to Metric (a bold run
1670        // reshapes, so the view must take the reshape path).
1671        assert_eq!(
1672            compute_range_kind(&[paint(0, 2), metric(4, 3), paint(8, 1)]),
1673            HighlighterKind::Metric
1674        );
1675    }
1676
1677    #[test]
1678    fn set_ranges_caches_the_kind_read_back_by_effective_kind() {
1679        let mut reg = HighlightRegistry::default();
1680        let id = reg.add_range(0, SessionVisibility::Shared);
1681        let positions = [(0u64, 0usize)];
1682        assert_eq!(
1683            reg.effective_kind(&HighlightMask::all()),
1684            HighlighterKind::None
1685        );
1686
1687        reg.set_ranges(id, vec![paint(0, 5)], &positions);
1688        assert_eq!(
1689            reg.effective_kind(&HighlightMask::all()),
1690            HighlighterKind::PaintOnly
1691        );
1692
1693        reg.set_ranges(id, vec![metric(0, 5)], &positions);
1694        assert_eq!(
1695            reg.effective_kind(&HighlightMask::all()),
1696            HighlighterKind::Metric,
1697            "the cached kind updates on every push"
1698        );
1699    }
1700
1701    // ── changed_extent: what a push actually touched ──
1702
1703    /// Re-pushing an identical set touched nothing, so a view has nothing to recolor. This is
1704    /// the case that matters most: a caret-driven layer re-pushes constantly.
1705    #[test]
1706    fn an_unchanged_push_reports_an_empty_extent() {
1707        let set = [paint(10, 5), paint(30, 5)];
1708        assert_eq!(changed_extent(&set, &set), (0, 0));
1709        assert_eq!(changed_extent(&[], &[]), (0, 0));
1710    }
1711
1712    /// A caret moving from one sentence to the next reports only the span covering both, so the
1713    /// recolor stays local instead of falling back to the whole document.
1714    #[test]
1715    fn a_moved_range_reports_the_span_covering_both_positions() {
1716        // 10..15 gone, 20..25 arrived → 10..25.
1717        assert_eq!(changed_extent(&[paint(10, 5)], &[paint(20, 5)]), (10, 15));
1718    }
1719
1720    #[test]
1721    fn adding_or_clearing_reports_just_that_range() {
1722        assert_eq!(changed_extent(&[], &[paint(7, 3)]), (7, 3));
1723        assert_eq!(changed_extent(&[paint(7, 3)], &[]), (7, 3));
1724    }
1725
1726    /// Ranges that survive the push contribute nothing, so a find session that keeps most of
1727    /// its matches reports only the ones that actually moved.
1728    #[test]
1729    fn unchanged_ranges_are_excluded_from_the_extent() {
1730        let old = [paint(0, 2), paint(50, 2)];
1731        let new = [paint(0, 2), paint(60, 2)];
1732        assert_eq!(changed_extent(&old, &new), (50, 12), "only the moved one");
1733    }
1734
1735    /// A format change at the same offsets — what a theme switch does — still reports the range,
1736    /// because `RangeHighlight` compares its format too.
1737    #[test]
1738    fn a_format_only_change_still_reports_its_range() {
1739        assert_eq!(changed_extent(&[paint(4, 6)], &[metric(4, 6)]), (4, 6));
1740    }
1741
1742    // ── build_block_index: bucketing ──
1743
1744    /// Blocks at 0, 10, 20 (each 10 wide). Ranges land in the block(s) they overlap.
1745    fn three_blocks() -> Vec<(u64, usize)> {
1746        vec![(100, 0), (101, 10), (102, 20)]
1747    }
1748
1749    fn bucket(index: &std::collections::HashMap<usize, Vec<u32>>, block: usize) -> Vec<u32> {
1750        index.get(&block).cloned().unwrap_or_default()
1751    }
1752
1753    #[test]
1754    fn a_range_lands_only_in_its_own_block() {
1755        let idx = build_block_index(&[paint(12, 3)], &three_blocks());
1756        assert_eq!(
1757            bucket(&idx, 101),
1758            vec![0],
1759            "12..15 is inside block 101 [10,20)"
1760        );
1761        assert!(bucket(&idx, 100).is_empty());
1762        assert!(bucket(&idx, 102).is_empty());
1763    }
1764
1765    #[test]
1766    fn a_straddling_range_lands_in_every_block_it_touches() {
1767        // 8..22 spans blocks 100 [0,10), 101 [10,20), 102 [20,∞).
1768        let idx = build_block_index(&[paint(8, 14)], &three_blocks());
1769        assert_eq!(bucket(&idx, 100), vec![0]);
1770        assert_eq!(bucket(&idx, 101), vec![0]);
1771        assert_eq!(bucket(&idx, 102), vec![0]);
1772    }
1773
1774    #[test]
1775    fn a_zero_length_range_buckets_into_its_block_but_paints_nothing() {
1776        // A degenerate zero-length range still buckets into the block that contains its point
1777        // (here 101), which is harmless: the per-block clip drops it (lo == hi), so it paints
1778        // nothing — proven end-to-end by the coverage differential in the integration tests.
1779        // It must not scatter into other blocks.
1780        let idx = build_block_index(&[paint(12, 0)], &three_blocks());
1781        assert_eq!(bucket(&idx, 101), vec![0]);
1782        assert!(bucket(&idx, 100).is_empty());
1783        assert!(bucket(&idx, 102).is_empty());
1784    }
1785
1786    #[test]
1787    fn an_out_of_range_start_is_bucketed_into_the_last_block_only_if_it_overlaps() {
1788        // start far past the end: only the last (unbounded) block could contain it, and it does
1789        // — the last block runs to usize::MAX — so it buckets there. That is harmless: the
1790        // per-block clip against real geometry drops it (start > block_end).
1791        let idx = build_block_index(&[paint(9999, 3)], &three_blocks());
1792        assert_eq!(
1793            bucket(&idx, 102),
1794            vec![0],
1795            "the unbounded last block is the only candidate"
1796        );
1797        assert!(bucket(&idx, 100).is_empty());
1798        assert!(bucket(&idx, 101).is_empty());
1799    }
1800
1801    #[test]
1802    fn empty_positions_yields_an_empty_index() {
1803        assert!(build_block_index(&[paint(0, 5)], &[]).is_empty());
1804    }
1805}