Skip to main content

twrite_core/
syntax.rs

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