1#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct ListChars {
15 pub tab_lead: char,
17 pub tab_fill: Option<char>,
19 pub space: Option<char>,
21 pub trail: Option<char>,
23 pub eol: Option<char>,
25 pub nbsp: Option<char>,
27 pub extends: Option<char>,
31 pub precedes: Option<char>,
35}
36
37impl Default for ListChars {
38 fn default() -> Self {
39 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 pub fn parse(s: &str) -> Result<Self, String> {
62 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 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 pub fn to_canonical_string(&self) -> String {
123 let mut parts: Vec<String> = Vec::new();
124 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
152fn 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
162pub 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 let tabstop = tabstop.max(1);
186
187 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; 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 out.push(lc.tab_lead);
202 col += 1;
203 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 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 if let Some(eol) = lc.eol {
240 out.push(eol);
241 }
242
243 std::borrow::Cow::Owned(out)
244}
245
246#[inline]
248fn unicode_width(ch: char) -> usize {
249 if is_wide(ch) { 2 } else { 1 }
253}
254
255#[inline]
257fn is_wide(ch: char) -> bool {
258 matches!(ch,
259 '\u{1100}'..='\u{115F}' | '\u{2E80}'..='\u{303E}' | '\u{3041}'..='\u{33BF}' | '\u{33FF}'..='\u{A4CF}' | '\u{A960}'..='\u{A97F}' | '\u{AC00}'..='\u{D7FF}' | '\u{F900}'..='\u{FAFF}' | '\u{FE10}'..='\u{FE1F}' | '\u{FE30}'..='\u{FE6F}' | '\u{FF00}'..='\u{FF60}' | '\u{FFE0}'..='\u{FFE6}' | '\u{1B000}'..='\u{1B0FF}' | '\u{1F004}' | '\u{1F0CF}' | '\u{1F200}'..='\u{1F2FF}' | '\u{20000}'..='\u{2A6DF}' | '\u{2A700}'..='\u{2CEAF}' | '\u{2CEB0}'..='\u{2EBEF}' | '\u{30000}'..='\u{3134F}' )
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284 use std::borrow::Cow;
285
286 #[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 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 #[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 let lc = ListChars::parse("tab:>-,eol:$").unwrap();
379 let result = apply_listchars("\tfoo", &lc, true, 4);
380 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 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 #[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 let input = "\t x\u{00a0} ";
420 let result = apply_listchars(input, &lc, true, 4);
421 assert_eq!(result.as_ref(), ">---·x_~¶");
428 }
429}