Skip to main content

twrite_core/
syntax.rs

1use std::ops::Range;
2
3use crate::EditorBuffer;
4
5/// An 8-bit per channel RGBA color representation.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
7pub struct Rgba {
8    /// Red color component (0-255).
9    pub r: u8,
10    /// Green color component (0-255).
11    pub g: u8,
12    /// Blue color component (0-255).
13    pub b: u8,
14    /// Alpha opacity component (0-255).
15    pub a: u8,
16}
17
18impl Rgba {
19    /// Creates a new color with red, green, blue, and alpha values.
20    pub const fn new(r: u8, g: u8, b: u8, a: u8) -> Self {
21        Self { r, g, b, a }
22    }
23
24    /// Creates an opaque color with red, green, and blue values.
25    pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
26        Self { r, g, b, a: 255 }
27    }
28
29    /// Converts hexadecimal color code (e.g. `0xFF5500`) to opaque RGBA.
30    pub const fn hex(hex: u32) -> Self {
31        let r = ((hex >> 16) & 0xFF) as u8;
32        let g = ((hex >> 8) & 0xFF) as u8;
33        let b = (hex & 0xFF) as u8;
34        Self { r, g, b, a: 255 }
35    }
36}
37
38/// Visual style for underlined text.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
40pub enum UnderlineDecoration {
41    /// Solid straight line.
42    Solid,
43    /// Wavy squiggly line.
44    Wavy,
45}
46
47/// Semantic categorization for syntax tokens.
48///
49/// The enum is split into namespaces: standard code tokens, universal document
50/// markup understood by the canvas (sizing, decorations), visual concealment
51/// mechanics, and an open extension point for custom languages. Batteries and
52/// custom highlighters must only emit these variants — never add new ones.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
54pub enum HighlightTag {
55    /// Programming language keywords.
56    Keyword,
57    /// Function names and declarations.
58    Function,
59    /// Type and struct names.
60    Type,
61    /// String literals.
62    String,
63    /// Numeric literals.
64    Number,
65    /// Comments.
66    Comment,
67    /// Operators.
68    Operator,
69    /// Punctuation symbols.
70    Punctuation,
71    /// Document heading; level is `1..=6`. Out-of-range levels render bold at
72    /// the base size (no scaling).
73    Heading(u8),
74    /// Bold text.
75    Bold,
76    /// Italic text.
77    Italic,
78    /// Marked/highlighted text (`==mark==`).
79    Highlight,
80    /// Monospace code spans or blocks.
81    Code,
82    /// Hyperlinks.
83    Link,
84    /// Structural tag: line is a blockquote (e.g. `> `). Used for decorations, not text color.
85    Blockquote,
86    /// Structural tag: line belongs to a callout/admonition block
87    /// (e.g. `> [!NOTE]`). Used for decorations, not text color.
88    Callout(CalloutKind),
89    /// Structural tag: line is a thematic break / horizontal rule. Used for decorations.
90    HorizontalRule,
91    /// Structural tag: line contains an unchecked task marker.
92    TaskUnchecked,
93    /// Structural tag: line contains a checked task marker.
94    TaskChecked,
95    /// Dimmed or concealed syntax delimiters (e.g. inactive document markers).
96    Dimmed,
97    /// Fully concealed / hidden syntax delimiters (transparent on inactive lines).
98    Hidden,
99    /// Open extension for custom languages and parsers (dotted names such as
100    /// `"speaker"` or `"sql.table"`). Unregistered names fall back to the
101    /// theme foreground; register colors via
102    /// `SyntaxTheme::set_custom_tag_color`.
103    Custom(&'static str),
104}
105
106/// Callout/admonition kind for `> [!KIND]` blocks. Unknown kinds map to
107/// `Other` so hosts render them with the default accent instead of failing.
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
109pub enum CalloutKind {
110    /// Informational note.
111    Note,
112    /// Helpful tip or hint.
113    Tip,
114    /// Warning that needs attention.
115    Warning,
116    /// Strong caution against an action.
117    Caution,
118    /// Critical importance marker.
119    Important,
120    /// Any other or unrecognized kind; renders with the default accent.
121    Other,
122}
123
124/// Direct styling attributes for a span of text.
125#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
126pub struct TextStyle {
127    /// Text foreground color.
128    pub color: Option<Rgba>,
129    /// Background highlight color.
130    pub background: Option<Rgba>,
131    /// Whether the text is rendered in bold font weight.
132    pub bold: bool,
133    /// Whether the text is rendered in italic font style.
134    pub italic: bool,
135    /// Underline decoration style if any.
136    pub underline: Option<UnderlineDecoration>,
137    /// Strikethrough line through the text.
138    pub strikethrough: bool,
139}
140
141impl TextStyle {
142    /// Creates an empty text style with all attributes set to default.
143    pub const fn new() -> Self {
144        Self {
145            color: None,
146            background: None,
147            bold: false,
148            italic: false,
149            underline: None,
150            strikethrough: false,
151        }
152    }
153
154    /// Sets foreground color.
155    pub const fn color(mut self, color: Rgba) -> Self {
156        self.color = Some(color);
157        self
158    }
159
160    /// Sets background color.
161    pub const fn background(mut self, background: Rgba) -> Self {
162        self.background = Some(background);
163        self
164    }
165
166    /// Sets bold attribute.
167    pub const fn bold(mut self) -> Self {
168        self.bold = true;
169        self
170    }
171
172    /// Sets italic attribute.
173    pub const fn italic(mut self) -> Self {
174        self.italic = true;
175        self
176    }
177
178    /// Sets underline attribute.
179    pub const fn underline(mut self, underline: UnderlineDecoration) -> Self {
180        self.underline = Some(underline);
181        self
182    }
183
184    /// Sets strikethrough attribute.
185    pub const fn strikethrough(mut self) -> Self {
186        self.strikethrough = true;
187        self
188    }
189}
190
191/// The style applied to a token, either via a semantic tag or direct visual attributes.
192#[derive(Debug, Clone, PartialEq, Eq)]
193pub enum StyleValue {
194    /// Styled according to theme's mapping for this semantic tag.
195    Tag(HighlightTag),
196    /// Explicit styling attributes.
197    Direct(TextStyle),
198}
199
200impl From<HighlightTag> for StyleValue {
201    fn from(tag: HighlightTag) -> Self {
202        Self::Tag(tag)
203    }
204}
205
206impl From<TextStyle> for StyleValue {
207    fn from(style: TextStyle) -> Self {
208        Self::Direct(style)
209    }
210}
211
212/// A styled region of text on a single line.
213#[derive(Debug, Clone, PartialEq, Eq)]
214pub struct StyleSpan {
215    /// Byte range within the line string (0-indexed).
216    pub range: Range<usize>,
217    /// The style applied to this range.
218    pub style: StyleValue,
219}
220
221impl StyleSpan {
222    /// Creates a new style span for a given range and style.
223    pub fn new(range: Range<usize>, style: impl Into<StyleValue>) -> Self {
224        Self {
225            range,
226            style: style.into(),
227        }
228    }
229
230    /// Convenience constructor for semantic tag styling.
231    pub fn tag(range: Range<usize>, tag: HighlightTag) -> Self {
232        Self {
233            range,
234            style: StyleValue::Tag(tag),
235        }
236    }
237
238    /// Convenience constructor for direct text styling.
239    pub fn direct(range: Range<usize>, style: TextStyle) -> Self {
240        Self {
241            range,
242            style: StyleValue::Direct(style),
243        }
244    }
245}
246
247/// Trait implemented by language tokenizers and syntax highlighters.
248pub trait SyntaxHighlighter: Send + Sync + 'static {
249    /// Analyzes a single line of text and returns all highlight spans for that line.
250    ///
251    /// Ranges in the returned `StyleSpan`s are byte offsets relative to `line_text`.
252    fn highlight_line(&self, buffer: &EditorBuffer, row: usize, line_text: &str) -> Vec<StyleSpan>;
253
254    /// Optional extraction of hyperlinks on this line (source byte range -> URL).
255    ///
256    /// The default implementation returns no links. Language plugins (e.g. Markdown)
257    /// override this to expose clickable ranges without the canvas knowing the syntax.
258    fn extract_links(
259        &self,
260        _buffer: &EditorBuffer,
261        _row: usize,
262        _line_text: &str,
263    ) -> Vec<(Range<usize>, String)> {
264        Vec::new()
265    }
266
267    /// Optional display-only padding for this line (e.g. table cell alignment).
268    ///
269    /// Receives the collapsed [`ConcealedLine`] and returns insertions in
270    /// collapsed display coordinates (see [`DisplayPad`]). The default
271    /// implementation returns no padding.
272    fn expand_line(
273        &self,
274        _buffer: &EditorBuffer,
275        _row: usize,
276        _concealed: &ConcealedLine,
277    ) -> Vec<DisplayPad> {
278        Vec::new()
279    }
280
281    /// Whether this line may soft-wrap when `line_wrap` is on.
282    ///
283    /// Batteries return `false` for rows whose display alignment would break
284    /// across visual lines (e.g. Markdown table rows). The default is `true`.
285    fn should_wrap_line(&self, _buffer: &EditorBuffer, _row: usize) -> bool {
286        true
287    }
288}
289
290/// A contiguous segment of text on a line with its resolved style and selection status.
291#[derive(Debug, Clone, PartialEq, Eq)]
292pub struct StyledSegment<'a> {
293    /// Byte range within the line.
294    pub range: Range<usize>,
295    /// The style value covering this segment, if any.
296    pub style: Option<&'a StyleValue>,
297    /// Whether this segment is within the active selection.
298    pub is_selected: bool,
299}
300
301/// Computes disjoint, sorted styled segments for a line by splitting across syntax span and selection boundaries.
302pub fn split_line_intervals<'a>(
303    line_len: usize,
304    spans: &'a [StyleSpan],
305    selection_range: Option<(usize, usize)>,
306) -> Vec<StyledSegment<'a>> {
307    if line_len == 0 {
308        return Vec::new();
309    }
310
311    let mut boundaries = Vec::with_capacity(spans.len() * 2 + 4);
312    boundaries.push(0);
313    boundaries.push(line_len);
314
315    if let Some((s_start, s_end)) = selection_range {
316        boundaries.push(s_start.min(line_len));
317        boundaries.push(s_end.min(line_len));
318    }
319
320    for span in spans {
321        boundaries.push(span.range.start.min(line_len));
322        boundaries.push(span.range.end.min(line_len));
323    }
324
325    boundaries.sort_unstable();
326    boundaries.dedup();
327
328    let mut segments = Vec::with_capacity(boundaries.len());
329
330    for window in boundaries.windows(2) {
331        let start = window[0];
332        let end = window[1];
333        if start >= end {
334            continue;
335        }
336
337        let is_selected = if let Some((s_start, s_end)) = selection_range {
338            start >= s_start && end <= s_end
339        } else {
340            false
341        };
342
343        let style = spans
344            .iter()
345            .rev()
346            .find(|s| s.range.start <= start && end <= s.range.end)
347            .map(|s| &s.style);
348
349        segments.push(StyledSegment {
350            range: start..end,
351            style,
352            is_selected,
353        });
354    }
355
356    segments
357}
358
359/// Display-column width of `s` for monospace alignment.
360///
361/// Counts most characters as one column and East-Asian wide/fullwidth
362/// characters as two; control characters are zero-width. Used by batteries
363/// that align display columns (e.g. Markdown tables). Only monospace fonts
364/// honor these columns; proportional fonts will misalign padded text.
365pub fn display_width(s: &str) -> usize {
366    use unicode_width::UnicodeWidthStr;
367    s.width()
368}
369
370/// Display-only padding spliced into a [`ConcealedLine`]'s display text.
371///
372/// Generic engine capability: batteries that align display columns (e.g.
373/// Markdown tables) return these from [`SyntaxHighlighter::expand_line`];
374/// the core splices the fill, remaps highlight spans, and extends the
375/// source/display byte map, so painting, cursor placement, selection, and
376/// hit-testing keep working unchanged. Padding bytes map back to the source
377/// offset of the display byte they were inserted before, so clicks and typing
378/// in padding land on that boundary.
379#[derive(Debug, Clone, Copy, PartialEq, Eq)]
380pub struct DisplayPad {
381    /// Collapsed-display byte offset the padding is inserted before
382    /// (`0..=display_text.len()`; snapped forward to a char boundary).
383    pub display_at: usize,
384    /// Fill character repeated [`DisplayPad::len`] times (usually `' '`).
385    pub fill: char,
386    /// Number of times [`DisplayPad::fill`] is repeated.
387    pub len: usize,
388}
389
390/// A rendered visual line where concealed syntax tokens have been collapsed,
391/// maintaining exact bidirectional mapping to source buffer byte offsets.
392#[derive(Debug, Clone)]
393pub struct ConcealedLine {
394    /// The transformed text to be shaped and rendered on screen.
395    pub display_text: String,
396    /// Syntax highlight spans adjusted to display text coordinates.
397    pub spans: Vec<StyleSpan>,
398    /// Map from display text byte offset to source buffer line byte offset.
399    byte_map: Vec<usize>,
400}
401
402impl ConcealedLine {
403    /// Constructs a concealed line from raw line text and syntax spans.
404    pub fn build(line_text: &str, spans: &[StyleSpan]) -> Self {
405        let has_hidden = spans
406            .iter()
407            .any(|s| matches!(s.style, StyleValue::Tag(HighlightTag::Hidden)));
408
409        if !has_hidden {
410            let byte_map = (0..=line_text.len()).collect();
411            return Self {
412                display_text: line_text.to_string(),
413                spans: spans.to_vec(),
414                byte_map,
415            };
416        }
417
418        let mut display_text = String::with_capacity(line_text.len());
419        let mut byte_map = Vec::with_capacity(line_text.len() + 1);
420
421        for (byte_idx, ch) in line_text.char_indices() {
422            let is_hidden = spans.iter().any(|s| {
423                matches!(s.style, StyleValue::Tag(HighlightTag::Hidden))
424                    && s.range.contains(&byte_idx)
425            });
426
427            if !is_hidden {
428                let ch_len = ch.len_utf8();
429                for b in 0..ch_len {
430                    byte_map.push(byte_idx + b);
431                }
432                display_text.push(ch);
433            }
434        }
435        byte_map.push(line_text.len());
436
437        let mut new_spans = Vec::new();
438        for span in spans {
439            if matches!(span.style, StyleValue::Tag(HighlightTag::Hidden)) {
440                continue;
441            }
442
443            let new_start = byte_map
444                .iter()
445                .position(|&src_idx| src_idx >= span.range.start)
446                .unwrap_or(display_text.len());
447            let new_end = byte_map
448                .iter()
449                .position(|&src_idx| src_idx >= span.range.end)
450                .unwrap_or(display_text.len());
451
452            if new_start < new_end {
453                new_spans.push(StyleSpan {
454                    range: new_start..new_end,
455                    style: span.style.clone(),
456                });
457            }
458        }
459
460        Self {
461            display_text,
462            spans: new_spans,
463            byte_map,
464        }
465    }
466
467    /// Returns a copy of this line with display-only padding spliced in.
468    ///
469    /// `pads` are applied in ascending `display_at` order against the
470    /// *original* collapsed coordinates. Span boundaries at or after an
471    /// insertion point shift right, so inserted padding belongs to the span
472    /// ending there. Each padding byte maps back to the source offset of the
473    /// display byte it was inserted before.
474    pub fn expanded(&self, pads: &[DisplayPad]) -> Self {
475        if pads.is_empty() {
476            return self.clone();
477        }
478        let mut sorted: Vec<DisplayPad> = pads.to_vec();
479        sorted.sort_by_key(|p| p.display_at);
480
481        let total_pad: usize = sorted.iter().map(|p| p.len * p.fill.len_utf8()).sum();
482        let mut display_text = String::with_capacity(self.display_text.len() + total_pad);
483        let mut byte_map = Vec::with_capacity(self.byte_map.len() + total_pad);
484
485        // Collapsed-display byte offset consumed so far.
486        let mut consumed = 0;
487
488        for pad in &sorted {
489            if pad.len == 0 {
490                continue;
491            }
492            let mut at = pad.display_at.min(self.display_text.len());
493            while at < self.display_text.len() && !self.display_text.is_char_boundary(at) {
494                at += 1;
495            }
496            if at < consumed {
497                continue;
498            }
499            display_text.push_str(&self.display_text[consumed..at]);
500            byte_map.extend_from_slice(&self.byte_map[consumed..at]);
501            let anchor = self.byte_map[at];
502            let fill: String = std::iter::repeat_n(pad.fill, pad.len).collect();
503            display_text.push_str(&fill);
504            byte_map.extend(std::iter::repeat_n(anchor, fill.len()));
505            consumed = at;
506        }
507        display_text.push_str(&self.display_text[consumed..]);
508        byte_map.extend_from_slice(&self.byte_map[consumed..]);
509
510        // Span boundaries at or after an insertion point shift right, so the
511        // inserted padding belongs to the span ending there.
512        let spans = self
513            .spans
514            .iter()
515            .map(|span| {
516                let shift = |b: usize| {
517                    let mut out = b;
518                    for pad in &sorted {
519                        if pad.display_at <= b {
520                            out += pad.len * pad.fill.len_utf8();
521                        } else {
522                            break;
523                        }
524                    }
525                    out
526                };
527                StyleSpan {
528                    range: shift(span.range.start)..shift(span.range.end),
529                    style: span.style.clone(),
530                }
531            })
532            .collect();
533
534        Self {
535            display_text,
536            spans,
537            byte_map,
538        }
539    }
540
541    /// Converts a display byte offset to a source buffer line byte offset.
542    pub fn display_to_source(&self, display_col: usize) -> usize {
543        if display_col >= self.byte_map.len() {
544            *self.byte_map.last().unwrap_or(&0)
545        } else {
546            self.byte_map[display_col]
547        }
548    }
549
550    /// Converts a source buffer line byte offset to the nearest display byte offset.
551    pub fn source_to_display(&self, source_col: usize) -> usize {
552        self.byte_map
553            .partition_point(|&src_idx| src_idx < source_col)
554            .min(self.display_text.len())
555    }
556}
557
558#[cfg(test)]
559mod tests {
560    use super::*;
561
562    struct MockHighlighter;
563
564    impl SyntaxHighlighter for MockHighlighter {
565        fn highlight_line(
566            &self,
567            _buffer: &EditorBuffer,
568            _row: usize,
569            line_text: &str,
570        ) -> Vec<StyleSpan> {
571            if line_text.starts_with("# ") {
572                vec![StyleSpan::tag(0..line_text.len(), HighlightTag::Heading(1))]
573            } else {
574                vec![]
575            }
576        }
577    }
578
579    #[test]
580    fn test_syntax_highlighter_trait() {
581        let buffer = EditorBuffer::new("# Title\nBody");
582        let highlighter = MockHighlighter;
583
584        let spans_0 = highlighter.highlight_line(&buffer, 0, "# Title");
585        assert_eq!(spans_0.len(), 1);
586        assert_eq!(spans_0[0].range, 0..7);
587        assert_eq!(spans_0[0].style, StyleValue::Tag(HighlightTag::Heading(1)));
588
589        let spans_1 = highlighter.highlight_line(&buffer, 1, "Body");
590        assert!(spans_1.is_empty());
591    }
592
593    #[test]
594    fn test_rgba_hex_conversion() {
595        let red = Rgba::hex(0xFF0000);
596        assert_eq!(red, Rgba::new(255, 0, 0, 255));
597
598        let custom = Rgba::hex(0x123456);
599        assert_eq!(custom, Rgba::new(0x12, 0x34, 0x56, 255));
600    }
601
602    #[test]
603    fn test_split_line_empty() {
604        let segments = split_line_intervals(0, &[], None);
605        assert!(segments.is_empty());
606    }
607
608    #[test]
609    fn test_split_line_plain_text() {
610        let segments = split_line_intervals(11, &[], None);
611        assert_eq!(segments.len(), 1);
612        assert_eq!(segments[0].range, 0..11);
613        assert_eq!(segments[0].style, None);
614        assert!(!segments[0].is_selected);
615    }
616
617    #[test]
618    fn test_split_line_with_single_span() {
619        let spans = vec![StyleSpan::tag(0..5, HighlightTag::Keyword)];
620        let segments = split_line_intervals(11, &spans, None);
621
622        assert_eq!(segments.len(), 2);
623        assert_eq!(segments[0].range, 0..5);
624        assert_eq!(
625            segments[0].style,
626            Some(&StyleValue::Tag(HighlightTag::Keyword))
627        );
628        assert!(!segments[0].is_selected);
629
630        assert_eq!(segments[1].range, 5..11);
631        assert_eq!(segments[1].style, None);
632        assert!(!segments[1].is_selected);
633    }
634
635    #[test]
636    fn test_split_line_with_overlapping_selection() {
637        let spans = vec![StyleSpan::tag(0..5, HighlightTag::Keyword)];
638        let segments = split_line_intervals(11, &spans, Some((3, 8)));
639
640        assert_eq!(segments.len(), 4);
641
642        assert_eq!(segments[0].range, 0..3);
643        assert_eq!(
644            segments[0].style,
645            Some(&StyleValue::Tag(HighlightTag::Keyword))
646        );
647        assert!(!segments[0].is_selected);
648
649        assert_eq!(segments[1].range, 3..5);
650        assert_eq!(
651            segments[1].style,
652            Some(&StyleValue::Tag(HighlightTag::Keyword))
653        );
654        assert!(segments[1].is_selected);
655
656        assert_eq!(segments[2].range, 5..8);
657        assert_eq!(segments[2].style, None);
658        assert!(segments[2].is_selected);
659
660        assert_eq!(segments[3].range, 8..11);
661        assert_eq!(segments[3].style, None);
662        assert!(!segments[3].is_selected);
663    }
664
665    #[test]
666    fn test_concealed_line_headings_align_and_collapse() {
667        let line1 = "# hello";
668        let spans1 = vec![
669            StyleSpan::tag(0..2, HighlightTag::Hidden),
670            StyleSpan::tag(2..7, HighlightTag::Heading(1)),
671        ];
672        let concealed1 = ConcealedLine::build(line1, &spans1);
673        assert_eq!(concealed1.display_text, "hello");
674        assert_eq!(concealed1.spans.len(), 1);
675        assert_eq!(concealed1.spans[0].range, 0..5);
676        assert_eq!(
677            concealed1.spans[0].style,
678            StyleValue::Tag(HighlightTag::Heading(1))
679        );
680        assert_eq!(concealed1.display_to_source(0), 2);
681        assert_eq!(concealed1.source_to_display(2), 0);
682
683        let line2 = "## hello";
684        let spans2 = vec![
685            StyleSpan::tag(0..3, HighlightTag::Hidden),
686            StyleSpan::tag(3..8, HighlightTag::Heading(2)),
687        ];
688        let concealed2 = ConcealedLine::build(line2, &spans2);
689        assert_eq!(concealed2.display_text, "hello");
690        assert_eq!(concealed2.spans.len(), 1);
691        assert_eq!(concealed2.spans[0].range, 0..5);
692        assert_eq!(
693            concealed2.spans[0].style,
694            StyleValue::Tag(HighlightTag::Heading(2))
695        );
696        assert_eq!(concealed2.display_to_source(0), 3);
697        assert_eq!(concealed2.source_to_display(3), 0);
698
699        assert_eq!(concealed1.display_text, concealed2.display_text);
700
701        let line_inline = "Hi **bold**!";
702        let spans_inline = vec![
703            StyleSpan::tag(3..5, HighlightTag::Hidden),
704            StyleSpan::tag(5..9, HighlightTag::Bold),
705            StyleSpan::tag(9..11, HighlightTag::Hidden),
706        ];
707        let concealed_inline = ConcealedLine::build(line_inline, &spans_inline);
708        assert_eq!(concealed_inline.display_text, "Hi bold!");
709        assert_eq!(concealed_inline.spans.len(), 1);
710        assert_eq!(concealed_inline.spans[0].range, 3..7);
711        assert_eq!(
712            concealed_inline.spans[0].style,
713            StyleValue::Tag(HighlightTag::Bold)
714        );
715        assert_eq!(concealed_inline.display_to_source(3), 5);
716        assert_eq!(concealed_inline.source_to_display(5), 3);
717    }
718
719    #[test]
720    fn test_display_width_columns() {
721        assert_eq!(display_width(""), 0);
722        assert_eq!(display_width("abc |"), 5);
723        assert_eq!(display_width("日本"), 4);
724        assert_eq!(display_width("a日本b"), 6);
725    }
726
727    #[test]
728    fn test_expanded_line_pads_and_maps() {
729        // Simulates one padded table row: `| a | b |` with two spaces of
730        // padding inserted before the middle pipe (display offset 4).
731        let line = "| a | b |";
732        let spans = vec![
733            StyleSpan::tag(1..3, HighlightTag::Custom("cell")),
734            StyleSpan::tag(4..5, HighlightTag::Punctuation),
735        ];
736        let base = ConcealedLine::build(line, &spans);
737        let padded = base.expanded(&[DisplayPad {
738            display_at: 4,
739            fill: ' ',
740            len: 2,
741        }]);
742        assert_eq!(padded.display_text, "| a   | b |");
743        // Spans ending before the insertion point are untouched; the pipe
744        // span at the insertion point shifts right past the padding.
745        assert!(
746            padded
747                .spans
748                .contains(&StyleSpan::tag(1..3, HighlightTag::Custom("cell")))
749        );
750        assert!(
751            padded
752                .spans
753                .contains(&StyleSpan::tag(6..7, HighlightTag::Punctuation))
754        );
755        // Padding bytes map back to the pipe's source offset (4).
756        assert_eq!(padded.display_to_source(4), 4);
757        assert_eq!(padded.display_to_source(5), 4);
758        assert_eq!(padded.display_to_source(6), 4);
759        // Source mapping stays total: every source byte still resolves.
760        assert_eq!(padded.source_to_display(4), 4);
761        assert_eq!(padded.source_to_display(5), 7);
762
763        // Empty pads are a no-op clone.
764        let same = base.expanded(&[]);
765        assert_eq!(same.display_text, base.display_text);
766        assert_eq!(same.spans, base.spans);
767    }
768
769    #[test]
770    fn test_highlighter_expansion_defaults_are_noops() {
771        let buffer = EditorBuffer::new("hello");
772        let highlighter = MockHighlighter;
773        let concealed = ConcealedLine::build("hello", &[]);
774        assert!(highlighter.expand_line(&buffer, 0, &concealed).is_empty());
775        assert!(highlighter.should_wrap_line(&buffer, 0));
776    }
777}