1use unicode_width::UnicodeWidthChar;
7
8#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct ListChars {
17 pub tab_lead: char,
19 pub tab_fill: Option<char>,
21 pub space: Option<char>,
23 pub trail: Option<char>,
25 pub eol: Option<char>,
27 pub nbsp: Option<char>,
29 pub extends: Option<char>,
33 pub precedes: Option<char>,
37}
38
39impl Default for ListChars {
40 fn default() -> Self {
41 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 pub fn parse(s: &str) -> Result<Self, String> {
64 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 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 pub fn to_canonical_string(&self) -> String {
125 let mut parts: Vec<String> = Vec::new();
126 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
154fn 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
164pub 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 let tabstop = tabstop.max(1);
188
189 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; 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 out.push(lc.tab_lead);
204 col += 1;
205 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 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 col += other.width().unwrap_or(1);
245 }
246 }
247 }
248
249 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 #[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 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 #[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 let lc = ListChars::parse("tab:>-,eol:$").unwrap();
355 let result = apply_listchars("\tfoo", &lc, true, 4);
356 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 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 #[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 #[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 #[test]
405 fn apply_listchars_combining_mark_counts_as_width_zero() {
406 let lc = ListChars::parse("tab:>-").unwrap();
407 let result = apply_listchars("a\u{0301}\tx", &lc, true, 4);
409 assert_eq!(result.as_ref(), "a\u{0301}>--x");
410 }
411
412 #[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 #[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 let input = "\t x\u{00a0} ";
435 let result = apply_listchars(input, &lc, true, 4);
436 assert_eq!(result.as_ref(), ">---·x_~¶");
443 }
444}