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