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