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