Skip to main content

hjkl_buffer/
listchars.rs

1//! Invisible-character rendering configuration.
2//!
3//! [`ListChars`] holds the glyph substitutions used when
4//! `:set list` is active. Mirrors vim's `listchars` option.
5
6/// Invisibles rendering configuration. Matches vim's `:set listchars`.
7///
8/// When `:set list` is on, the render layer substitutes whitespace characters
9/// with the glyphs configured here. `None` fields mean "no substitution /
10/// not rendered".
11///
12/// Default matches vim's built-in default: `tab:^I,eol:$`.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct ListChars {
15    /// Leading char of a tab expansion (required). E.g. `>` in `tab:>-`.
16    pub tab_lead: char,
17    /// Fill char repeated to next tabstop. `None` = single-glyph tab (no fill).
18    pub tab_fill: Option<char>,
19    /// Substitution for regular spaces. `None` = no substitution (vim default).
20    pub space: Option<char>,
21    /// Substitution for trailing whitespace. `None` = falls back to `space` or no render.
22    pub trail: Option<char>,
23    /// Marker appended after the last char on each line. `None` = no marker.
24    pub eol: Option<char>,
25    /// Substitution for non-breaking spaces (`\u{00a0}`). `None` = no substitution.
26    pub nbsp: Option<char>,
27    /// Char shown at the right edge when a line extends beyond the viewport
28    /// (no-wrap mode). `None` = no marker.
29    /// TODO: deferred — requires viewport edge integration.
30    pub extends: Option<char>,
31    /// Char shown at the left edge when the viewport is scrolled right past
32    /// the line start. `None` = no marker.
33    /// TODO: deferred — requires viewport edge integration.
34    pub precedes: Option<char>,
35}
36
37impl Default for ListChars {
38    fn default() -> Self {
39        // vim built-in default: tab:^I,eol:$
40        Self {
41            tab_lead: '^',
42            tab_fill: Some('I'),
43            space: None,
44            trail: None,
45            eol: Some('$'),
46            nbsp: None,
47            extends: None,
48            precedes: None,
49        }
50    }
51}
52
53impl ListChars {
54    /// Parse a vim-style `listchars` value string.
55    ///
56    /// Accepts comma-separated `key:value` pairs where value is one or two
57    /// chars (UTF-8). `tab` is the only key that may have two chars
58    /// (`tab:lead_fill`); all others take exactly one char.
59    ///
60    /// Returns `Err(String)` with a diagnostic on unknown keys or bad values.
61    pub fn parse(s: &str) -> Result<Self, String> {
62        // Start from a blank slate (all None). The `tab` key is required
63        // for the resulting value to be valid; if the caller omits it the
64        // existing tab_lead/tab_fill remain at the blank-slate defaults
65        // (`^` + `I`) which matches vim's initial default.
66        let mut lc = Self {
67            tab_lead: '^',
68            tab_fill: Some('I'),
69            space: None,
70            trail: None,
71            eol: None,
72            nbsp: None,
73            extends: None,
74            precedes: None,
75        };
76        for raw_part in s.split(',') {
77            // Only trim leading whitespace (not trailing — a trailing space
78            // is a valid single-char value, e.g. `tab:→ ` where space is
79            // the fill char).
80            let part = raw_part.trim_start();
81            if part.is_empty() {
82                continue;
83            }
84            let (key, val) = part
85                .split_once(':')
86                .ok_or_else(|| format!("listchars: missing `:` in `{part}`"))?;
87            let chars: Vec<char> = val.chars().collect();
88            match key {
89                "tab" => match chars.len() {
90                    1 => {
91                        lc.tab_lead = chars[0];
92                        lc.tab_fill = None;
93                    }
94                    2 => {
95                        lc.tab_lead = chars[0];
96                        lc.tab_fill = Some(chars[1]);
97                    }
98                    n => {
99                        return Err(format!(
100                            "listchars: `tab` value must be 1 or 2 chars, got {n}"
101                        ));
102                    }
103                },
104                "space" => lc.space = Some(one_char(key, &chars)?),
105                "trail" => lc.trail = Some(one_char(key, &chars)?),
106                "eol" => lc.eol = Some(one_char(key, &chars)?),
107                "nbsp" => lc.nbsp = Some(one_char(key, &chars)?),
108                "extends" => lc.extends = Some(one_char(key, &chars)?),
109                "precedes" => lc.precedes = Some(one_char(key, &chars)?),
110                other => {
111                    return Err(format!("listchars: unknown key `{other}`"));
112                }
113            }
114        }
115        Ok(lc)
116    }
117
118    /// Canonical string form for `:set listchars?`.
119    ///
120    /// Emits only the fields that are set (non-None), always in the order:
121    /// `tab`, `space`, `trail`, `eol`, `nbsp`, `extends`, `precedes`.
122    pub fn to_canonical_string(&self) -> String {
123        let mut parts: Vec<String> = Vec::new();
124        // tab is always present
125        if let Some(fill) = self.tab_fill {
126            parts.push(format!("tab:{}{}", self.tab_lead, fill));
127        } else {
128            parts.push(format!("tab:{}", self.tab_lead));
129        }
130        if let Some(ch) = self.space {
131            parts.push(format!("space:{ch}"));
132        }
133        if let Some(ch) = self.trail {
134            parts.push(format!("trail:{ch}"));
135        }
136        if let Some(ch) = self.eol {
137            parts.push(format!("eol:{ch}"));
138        }
139        if let Some(ch) = self.nbsp {
140            parts.push(format!("nbsp:{ch}"));
141        }
142        if let Some(ch) = self.extends {
143            parts.push(format!("extends:{ch}"));
144        }
145        if let Some(ch) = self.precedes {
146            parts.push(format!("precedes:{ch}"));
147        }
148        parts.join(",")
149    }
150}
151
152/// Extract exactly one char from `chars`, returning an error if count != 1.
153fn one_char(key: &str, chars: &[char]) -> Result<char, String> {
154    match chars.len() {
155        1 => Ok(chars[0]),
156        n => Err(format!(
157            "listchars: `{key}` value must be exactly 1 char, got {n}"
158        )),
159    }
160}
161
162/// Apply listchars substitutions to a line string.
163///
164/// When `list` is false, returns `Cow::Borrowed(line)` with no allocation.
165/// When `list` is true, walks the line and substitutes:
166/// - `\t` → `tab_lead` + `tab_fill` × (tabstop - col % tabstop - 1)
167/// - trailing spaces → `trail` glyph (if `Some`), else `space` glyph (if `Some`)
168/// - end-of-line → `eol` glyph (if `Some`) appended after all chars
169/// - `\u{00a0}` → `nbsp` glyph (if `Some`)
170/// - regular spaces → `space` glyph (if `Some`)
171///
172/// Note: extends/precedes (viewport edge markers) are deferred — handled by
173/// the renderer at the cell-paint level, not pre-processed here.
174pub fn apply_listchars<'a>(
175    line: &'a str,
176    lc: &ListChars,
177    list: bool,
178    tabstop: usize,
179) -> std::borrow::Cow<'a, str> {
180    if !list {
181        return std::borrow::Cow::Borrowed(line);
182    }
183
184    // Guard against a zero tabstop from the host — `col % 0` panics.
185    let tabstop = tabstop.max(1);
186
187    // Find the index of the first trailing whitespace char.
188    // "trailing whitespace" = spaces/tabs at the end of the line that would
189    // be rendered with `trail` glyph.
190    let trimmed_end = line.trim_end_matches([' ', '\t']).len();
191
192    let mut out = String::with_capacity(line.len() + 8);
193    let mut col: usize = 0; // visible column counter (for tab expansion)
194
195    for (byte_idx, ch) in line.char_indices() {
196        let is_trailing = byte_idx >= trimmed_end;
197        match ch {
198            '\t' => {
199                let spaces = tabstop - (col % tabstop);
200                // tab_lead is always the first cell
201                out.push(lc.tab_lead);
202                col += 1;
203                // fill remaining cells
204                let fill_count = spaces.saturating_sub(1);
205                if let Some(fill) = lc.tab_fill {
206                    for _ in 0..fill_count {
207                        out.push(fill);
208                        col += 1;
209                    }
210                } else {
211                    // single-glyph tab: pad with spaces to honour tabstop
212                    for _ in 0..fill_count {
213                        out.push(' ');
214                        col += 1;
215                    }
216                }
217            }
218            ' ' => {
219                let sub = if is_trailing {
220                    lc.trail.or(lc.space).unwrap_or(' ')
221                } else {
222                    lc.space.unwrap_or(' ')
223                };
224                out.push(sub);
225                col += 1;
226            }
227            '\u{00a0}' => {
228                out.push(lc.nbsp.unwrap_or('\u{00a0}'));
229                col += 1;
230            }
231            other => {
232                out.push(other);
233                col += unicode_width(other);
234            }
235        }
236    }
237
238    // Append eol marker
239    if let Some(eol) = lc.eol {
240        out.push(eol);
241    }
242
243    std::borrow::Cow::Owned(out)
244}
245
246/// Unicode display width for a char (1 for most, 2 for CJK wide chars, 0 for controls).
247#[inline]
248fn unicode_width(ch: char) -> usize {
249    // Use a simple approximation: CJK wide = 2, everything else = 1.
250    // This avoids adding unicode-width as a direct dep here; buffer-tui
251    // uses the real UnicodeWidthChar for rendering.
252    if is_wide(ch) { 2 } else { 1 }
253}
254
255/// Very small is_wide predicate covering the most common CJK blocks.
256#[inline]
257fn is_wide(ch: char) -> bool {
258    matches!(ch,
259        '\u{1100}'..='\u{115F}'   // Hangul Jamo
260        | '\u{2E80}'..='\u{303E}' // CJK Radicals
261        | '\u{3041}'..='\u{33BF}' // Hiragana/Katakana/CJK
262        | '\u{33FF}'..='\u{A4CF}' // CJK Unified
263        | '\u{A960}'..='\u{A97F}' // Hangul extension
264        | '\u{AC00}'..='\u{D7FF}' // Hangul Syllables
265        | '\u{F900}'..='\u{FAFF}' // CJK Compatibility
266        | '\u{FE10}'..='\u{FE1F}' // Vertical forms
267        | '\u{FE30}'..='\u{FE6F}' // CJK Compatibility forms
268        | '\u{FF00}'..='\u{FF60}' // Fullwidth
269        | '\u{FFE0}'..='\u{FFE6}' // Fullwidth signs
270        | '\u{1B000}'..='\u{1B0FF}' // Kana Supplement
271        | '\u{1F004}'              // Mahjong tile
272        | '\u{1F0CF}'              // Playing card
273        | '\u{1F200}'..='\u{1F2FF}' // Enclosed CJK
274        | '\u{20000}'..='\u{2A6DF}' // CJK Unified Ext B
275        | '\u{2A700}'..='\u{2CEAF}' // CJK Unified Ext C/D/E
276        | '\u{2CEB0}'..='\u{2EBEF}' // CJK Unified Ext F
277        | '\u{30000}'..='\u{3134F}' // CJK Unified Ext G
278    )
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284    use std::borrow::Cow;
285
286    // ---- ListChars::parse tests ----
287
288    #[test]
289    fn listchars_parse_basic() {
290        let lc = ListChars::parse("tab:>-,eol:$").unwrap();
291        assert_eq!(lc.tab_lead, '>');
292        assert_eq!(lc.tab_fill, Some('-'));
293        assert_eq!(lc.eol, Some('$'));
294        assert_eq!(lc.space, None);
295        assert_eq!(lc.trail, None);
296    }
297
298    #[test]
299    fn listchars_parse_all_keys() {
300        let lc =
301            ListChars::parse("tab:>-,space:·,trail:~,eol:¶,nbsp:_,extends:>,precedes:<").unwrap();
302        assert_eq!(lc.tab_lead, '>');
303        assert_eq!(lc.tab_fill, Some('-'));
304        assert_eq!(lc.space, Some('·'));
305        assert_eq!(lc.trail, Some('~'));
306        assert_eq!(lc.eol, Some('¶'));
307        assert_eq!(lc.nbsp, Some('_'));
308        assert_eq!(lc.extends, Some('>'));
309        assert_eq!(lc.precedes, Some('<'));
310    }
311
312    #[test]
313    fn listchars_parse_utf8() {
314        let lc = ListChars::parse("tab:→ ,eol:¬").unwrap();
315        assert_eq!(lc.tab_lead, '→');
316        assert_eq!(lc.tab_fill, Some(' '));
317        assert_eq!(lc.eol, Some('¬'));
318    }
319
320    #[test]
321    fn listchars_parse_invalid_no_colon() {
322        assert!(ListChars::parse("tab").is_err());
323    }
324
325    #[test]
326    fn listchars_parse_invalid_three_char_tab() {
327        assert!(ListChars::parse("tab:abc").is_err());
328    }
329
330    #[test]
331    fn listchars_parse_invalid_unknown_key() {
332        assert!(ListChars::parse("bogus:x").is_err());
333    }
334
335    #[test]
336    fn listchars_parse_invalid_returns_err() {
337        // All three error cases from the spec
338        assert!(ListChars::parse("tab").is_err(), "no colon");
339        assert!(ListChars::parse("tab:abc").is_err(), "3-char tab value");
340        assert!(ListChars::parse("bogus:x").is_err(), "unknown key");
341    }
342
343    #[test]
344    fn listchars_to_string_roundtrip() {
345        let s = "tab:>-,space:·,trail:~,eol:¶,nbsp:_,extends:>,precedes:<";
346        let lc1 = ListChars::parse(s).unwrap();
347        let canonical = lc1.to_canonical_string();
348        let lc2 = ListChars::parse(&canonical).unwrap();
349        assert_eq!(lc1, lc2);
350    }
351
352    #[test]
353    fn listchars_default_matches_vim() {
354        let lc = ListChars::default();
355        assert_eq!(lc.tab_lead, '^');
356        assert_eq!(lc.tab_fill, Some('I'));
357        assert_eq!(lc.eol, Some('$'));
358        assert_eq!(lc.space, None);
359        assert_eq!(lc.trail, None);
360        assert_eq!(lc.nbsp, None);
361    }
362
363    // ---- apply_listchars tests ----
364
365    #[test]
366    fn apply_listchars_off_returns_borrowed() {
367        let lc = ListChars::default();
368        let result = apply_listchars("hello world", &lc, false, 4);
369        assert!(
370            matches!(result, Cow::Borrowed(_)),
371            "expected Borrowed when list=false"
372        );
373    }
374
375    #[test]
376    fn apply_listchars_tab_expansion() {
377        // tab:>- at col 0 with tabstop=4 → ">---foo"
378        let lc = ListChars::parse("tab:>-,eol:$").unwrap();
379        let result = apply_listchars("\tfoo", &lc, true, 4);
380        // tab at col 0 → 4 wide: '>' + '-' + '-' + '-', then "foo", then '$'
381        assert_eq!(result.as_ref(), ">---foo$");
382    }
383
384    #[test]
385    fn apply_listchars_trail_substitution() {
386        let lc = ListChars::parse("tab:>-,trail:·").unwrap();
387        // eol=None so no eol marker; space=None so interior spaces stay as ' '
388        let result = apply_listchars("foo   ", &lc, true, 4);
389        assert_eq!(result.as_ref(), "foo···");
390    }
391
392    #[test]
393    fn apply_listchars_eol_appended() {
394        let lc = ListChars::parse("tab:>-,eol:¶").unwrap();
395        let result = apply_listchars("foo", &lc, true, 4);
396        assert_eq!(result.as_ref(), "foo¶");
397    }
398
399    #[test]
400    fn apply_listchars_nbsp_substitution() {
401        let lc = ListChars::parse("tab:>-,nbsp:_").unwrap();
402        let result = apply_listchars("a\u{00a0}b", &lc, true, 4);
403        assert_eq!(result.as_ref(), "a_b");
404    }
405
406    /// Regression: `tabstop == 0` used to hit `col % 0` and panic when the
407    /// line contained a tab. Zero is normalised to 1.
408    #[test]
409    fn apply_listchars_tabstop_zero_does_not_panic() {
410        let lc = ListChars::parse("tab:>-").unwrap();
411        let result = apply_listchars("\tx", &lc, true, 0);
412        assert_eq!(result.as_ref(), ">x");
413    }
414
415    #[test]
416    fn apply_listchars_combined() {
417        let lc = ListChars::parse("tab:>-,space:·,trail:~,eol:¶,nbsp:_").unwrap();
418        // line: tab, space, 'x', nbsp, trailing space
419        let input = "\t x\u{00a0} ";
420        let result = apply_listchars(input, &lc, true, 4);
421        // tab at col 0 with tabstop=4 → ">---"
422        // interior space → '·'
423        // 'x' → 'x'
424        // nbsp → '_'
425        // trailing space → '~'
426        // eol → '¶'
427        assert_eq!(result.as_ref(), ">---·x_~¶");
428    }
429}