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