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// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
160// Internal storage
161// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
162
163/// Per-block highlight state.
164pub(crate) struct BlockHighlightData {
165    pub spans: Vec<HighlightSpan>,
166    pub state: i64,
167    pub user_data: Option<Box<dyn Any + Send + Sync>>,
168}
169
170/// All highlight data for the document.
171pub(crate) struct HighlightData {
172    pub highlighter: Arc<dyn SyntaxHighlighter>,
173    pub blocks: HashMap<usize, BlockHighlightData>,
174}
175
176// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
177// Merge algorithm
178// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
179
180/// Apply highlight format overrides onto a base `TextFormat`.
181fn apply_highlight(base: &TextFormat, hl: &HighlightFormat) -> TextFormat {
182    TextFormat {
183        font_family: hl.font_family.clone().or_else(|| base.font_family.clone()),
184        font_point_size: hl.font_point_size.or(base.font_point_size),
185        font_weight: hl.font_weight.or(base.font_weight),
186        font_bold: hl.font_bold.or(base.font_bold),
187        font_italic: hl.font_italic.or(base.font_italic),
188        font_underline: hl.font_underline.or(base.font_underline),
189        font_overline: hl.font_overline.or(base.font_overline),
190        font_strikeout: hl.font_strikeout.or(base.font_strikeout),
191        letter_spacing: hl.letter_spacing.or(base.letter_spacing),
192        word_spacing: hl.word_spacing.or(base.word_spacing),
193        underline_style: hl
194            .underline_style
195            .clone()
196            .or_else(|| base.underline_style.clone()),
197        vertical_alignment: hl
198            .vertical_alignment
199            .clone()
200            .or_else(|| base.vertical_alignment.clone()),
201        tooltip: hl.tooltip.clone().or_else(|| base.tooltip.clone()),
202        foreground_color: hl.foreground_color.or(base.foreground_color),
203        background_color: hl.background_color.or(base.background_color),
204        underline_color: hl.underline_color.or(base.underline_color),
205        // Anchors are not overridden by highlights.
206        anchor_href: base.anchor_href.clone(),
207        anchor_names: base.anchor_names.clone(),
208        is_anchor: base.is_anchor,
209    }
210}
211
212/// Merge a set of overlapping highlights into a single `HighlightFormat`.
213/// Later spans override earlier spans for the same field.
214fn merge_overlapping_highlights(spans: &[&HighlightSpan]) -> HighlightFormat {
215    let mut merged = HighlightFormat::default();
216    for span in spans {
217        let f = &span.format;
218        if f.foreground_color.is_some() {
219            merged.foreground_color = f.foreground_color;
220        }
221        if f.background_color.is_some() {
222            merged.background_color = f.background_color;
223        }
224        if f.underline_color.is_some() {
225            merged.underline_color = f.underline_color;
226        }
227        if f.font_family.is_some() {
228            merged.font_family = f.font_family.clone();
229        }
230        if f.font_point_size.is_some() {
231            merged.font_point_size = f.font_point_size;
232        }
233        if f.font_weight.is_some() {
234            merged.font_weight = f.font_weight;
235        }
236        if f.font_bold.is_some() {
237            merged.font_bold = f.font_bold;
238        }
239        if f.font_italic.is_some() {
240            merged.font_italic = f.font_italic;
241        }
242        if f.font_underline.is_some() {
243            merged.font_underline = f.font_underline;
244        }
245        if f.font_overline.is_some() {
246            merged.font_overline = f.font_overline;
247        }
248        if f.font_strikeout.is_some() {
249            merged.font_strikeout = f.font_strikeout;
250        }
251        if f.letter_spacing.is_some() {
252            merged.letter_spacing = f.letter_spacing;
253        }
254        if f.word_spacing.is_some() {
255            merged.word_spacing = f.word_spacing;
256        }
257        if f.underline_style.is_some() {
258            merged.underline_style = f.underline_style.clone();
259        }
260        if f.vertical_alignment.is_some() {
261            merged.vertical_alignment = f.vertical_alignment.clone();
262        }
263        if f.tooltip.is_some() {
264            merged.tooltip = f.tooltip.clone();
265        }
266    }
267    merged
268}
269
270/// Merge highlight spans into a list of fragments.
271///
272/// Text fragments that overlap with highlight spans are split at span
273/// boundaries. The highlight format is overlaid onto the base `TextFormat`.
274/// Image fragments receive the overlay without splitting.
275/// Local copy of the word-start computation from `text_block.rs`:
276/// returns character indices (not byte offsets) where a Unicode word
277/// starts, per UAX #29. Mirrors the upstream helper so highlight
278/// splits produce accessibility-correct word_starts for each
279/// sub-fragment without reaching into `text_block`.
280fn compute_word_starts_local(text: &str) -> Vec<u8> {
281    use unicode_segmentation::UnicodeSegmentation;
282    let mut result = Vec::new();
283    let mut byte_to_char: Vec<(usize, usize)> = Vec::new();
284    for (ci, (bi, _)) in text.char_indices().enumerate() {
285        byte_to_char.push((bi, ci));
286    }
287    for (byte_off, _word) in text.unicode_word_indices() {
288        let char_idx = byte_to_char
289            .iter()
290            .find(|(bi, _)| *bi == byte_off)
291            .map(|(_, ci)| *ci)
292            .unwrap_or(0);
293        if let Ok(idx) = u8::try_from(char_idx) {
294            result.push(idx);
295        } else {
296            break;
297        }
298    }
299    result
300}
301
302pub(crate) fn merge_highlight_spans(
303    fragments: Vec<FragmentContent>,
304    spans: &[HighlightSpan],
305) -> Vec<FragmentContent> {
306    if spans.is_empty() {
307        return fragments;
308    }
309
310    let mut result = Vec::with_capacity(fragments.len());
311
312    for frag in fragments {
313        match frag {
314            FragmentContent::Text {
315                ref text,
316                ref format,
317                offset,
318                length,
319                element_id,
320                word_starts: _,
321            } => {
322                let frag_end = offset + length;
323
324                // Collect highlight boundaries within this fragment's range.
325                let mut boundaries = Vec::new();
326                boundaries.push(offset);
327                boundaries.push(frag_end);
328
329                for span in spans {
330                    let span_end = span.start + span.length;
331                    // Does this span overlap the fragment?
332                    if span.start < frag_end && span_end > offset {
333                        if span.start > offset && span.start < frag_end {
334                            boundaries.push(span.start);
335                        }
336                        if span_end > offset && span_end < frag_end {
337                            boundaries.push(span_end);
338                        }
339                    }
340                }
341
342                boundaries.sort_unstable();
343                boundaries.dedup();
344
345                // Split the text at each boundary and apply overlapping highlights.
346                let chars: Vec<char> = text.chars().collect();
347                for window in boundaries.windows(2) {
348                    let sub_start = window[0];
349                    let sub_end = window[1];
350                    let sub_len = sub_end - sub_start;
351                    if sub_len == 0 {
352                        continue;
353                    }
354
355                    // Collect all highlight spans overlapping [sub_start, sub_end).
356                    let active: Vec<&HighlightSpan> = spans
357                        .iter()
358                        .filter(|s| {
359                            let s_end = s.start + s.length;
360                            s.start < sub_end && s_end > sub_start
361                        })
362                        .collect();
363
364                    let char_start = sub_start - offset;
365                    let char_end = char_start + sub_len;
366                    let sub_text: String = chars[char_start..char_end].iter().collect();
367
368                    let sub_format = if active.is_empty() {
369                        format.clone()
370                    } else {
371                        let merged_hl = merge_overlapping_highlights(&active);
372                        apply_highlight(format, &merged_hl)
373                    };
374
375                    let sub_word_starts = compute_word_starts_local(&sub_text);
376                    result.push(FragmentContent::Text {
377                        text: sub_text,
378                        format: sub_format,
379                        offset: sub_start,
380                        length: sub_len,
381                        // All sub-fragments split from one source
382                        // `FragmentContent::Text` reference the same
383                        // underlying format run — only the highlight
384                        // formatting differs. Sharing the id is
385                        // correct for accessibility (the underlying
386                        // text belongs to one stable run) at the cost
387                        // that synthetic NodeIds for highlighted
388                        // sub-runs collide unless the caller further
389                        // disambiguates.
390                        // The fern-widgets layer handles that by
391                        // mixing the `offset` into the synthetic-id
392                        // hash alongside `element_id`.
393                        element_id,
394                        word_starts: sub_word_starts,
395                    });
396                }
397            }
398            FragmentContent::Image {
399                ref name,
400                width,
401                height,
402                quality,
403                ref format,
404                offset,
405                element_id,
406            } => {
407                // Find overlapping highlights for this single-char position.
408                let active: Vec<&HighlightSpan> = spans
409                    .iter()
410                    .filter(|s| {
411                        let s_end = s.start + s.length;
412                        s.start < offset + 1 && s_end > offset
413                    })
414                    .collect();
415
416                let img_format = if active.is_empty() {
417                    format.clone()
418                } else {
419                    let merged_hl = merge_overlapping_highlights(&active);
420                    apply_highlight(format, &merged_hl)
421                };
422
423                result.push(FragmentContent::Image {
424                    name: name.clone(),
425                    width,
426                    height,
427                    quality,
428                    format: img_format,
429                    offset,
430                    element_id,
431                });
432            }
433        }
434    }
435
436    result
437}
438
439// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
440// Re-highlighting
441// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
442
443/// Get all block IDs sorted by document_position.
444fn ordered_block_ids(inner: &TextDocumentInner) -> Vec<(u64, String)> {
445    let mut blocks = block_commands::get_all_block(&inner.ctx).unwrap_or_default();
446    let store = inner.ctx.db_context.get_store();
447    crate::inner::refresh_block_positions(&mut blocks, store);
448    blocks.sort_by_key(|b| b.document_position);
449    blocks
450        .into_iter()
451        .map(|b| {
452            let entity: common::entities::Block = b.clone().into();
453            let text = common::database::rope_helpers::block_content_via_store(&entity, store);
454            (b.id, text)
455        })
456        .collect()
457}
458
459impl TextDocumentInner {
460    /// Re-highlight all blocks in the document.
461    pub(crate) fn rehighlight_all(&mut self) {
462        let hl = match self.highlight {
463            Some(ref mut hl) => hl,
464            None => return,
465        };
466
467        let highlighter = Arc::clone(&hl.highlighter);
468        hl.blocks.clear();
469
470        let blocks = ordered_block_ids(self);
471        let mut previous_state: i64 = -1;
472
473        for (block_id, text) in &blocks {
474            let bid = *block_id as usize;
475            let mut ctx = HighlightContext::new(bid, previous_state, None);
476            highlighter.highlight_block(text, &mut ctx);
477            let (spans, state, user_data) = ctx.into_parts();
478
479            previous_state = state;
480
481            // Only store if there's something to store.
482            let hl = self.highlight.as_mut().unwrap();
483            hl.blocks.insert(
484                bid,
485                BlockHighlightData {
486                    spans,
487                    state,
488                    user_data,
489                },
490            );
491        }
492    }
493
494    /// Re-highlight starting from a specific block, cascading until the
495    /// block state stabilizes or the end of the document is reached.
496    pub(crate) fn rehighlight_from_block(&mut self, start_block_id: usize) {
497        let hl = match self.highlight {
498            Some(ref hl) => hl,
499            None => return,
500        };
501
502        let highlighter = Arc::clone(&hl.highlighter);
503        let blocks = ordered_block_ids(self);
504
505        // Find the starting index.
506        let start_idx = match blocks
507            .iter()
508            .position(|(id, _)| *id as usize == start_block_id)
509        {
510            Some(idx) => idx,
511            None => return,
512        };
513
514        for i in start_idx..blocks.len() {
515            let (block_id, ref text) = blocks[i];
516            let bid = block_id as usize;
517
518            let hl = self.highlight.as_ref().unwrap();
519
520            // Get previous block's state.
521            let previous_state = if i == 0 {
522                -1
523            } else {
524                let prev_bid = blocks[i - 1].0 as usize;
525                hl.blocks.get(&prev_bid).map_or(-1, |d| d.state)
526            };
527
528            // Take existing user data if available.
529            let user_data = self
530                .highlight
531                .as_mut()
532                .unwrap()
533                .blocks
534                .get_mut(&bid)
535                .and_then(|d| d.user_data.take());
536
537            let old_state = self
538                .highlight
539                .as_ref()
540                .unwrap()
541                .blocks
542                .get(&bid)
543                .map_or(-1, |d| d.state);
544
545            let mut ctx = HighlightContext::new(bid, previous_state, user_data);
546            highlighter.highlight_block(text, &mut ctx);
547            let (spans, state, user_data) = ctx.into_parts();
548
549            let hl = self.highlight.as_mut().unwrap();
550            hl.blocks.insert(
551                bid,
552                BlockHighlightData {
553                    spans,
554                    state,
555                    user_data,
556                },
557            );
558
559            // If we are past the initial block and the state didn't change,
560            // stop cascading.
561            if i > start_idx && state == old_state {
562                break;
563            }
564        }
565    }
566
567    /// Re-highlight blocks affected by a content change at the given
568    /// document position.
569    pub(crate) fn rehighlight_affected(&mut self, position: usize) {
570        if self.highlight.is_none() {
571            return;
572        }
573
574        let blocks = ordered_block_ids(self);
575
576        let store = self.ctx.db_context.get_store();
577        // Find the block that contains `position`.
578        let target_bid = blocks
579            .iter()
580            .rev()
581            .find_map(|(id, _)| {
582                let mut dto = block_commands::get_block(&self.ctx, id).ok().flatten()?;
583                crate::inner::refresh_block_position(&mut dto, store);
584                let bp = dto.document_position as usize;
585                if position >= bp {
586                    Some(*id as usize)
587                } else {
588                    None
589                }
590            })
591            .unwrap_or_else(|| blocks.first().map_or(0, |(id, _)| *id as usize));
592
593        if blocks.is_empty() {
594            return;
595        }
596
597        self.rehighlight_from_block(target_bid);
598    }
599}