Skip to main content

oo_ide/
path_format.rs

1use std::borrow::Cow;
2use std::ffi::OsStr;
3use std::path::{Component, Path};
4
5use ratatui::style::Style;
6use ratatui::text::{Line, Span};
7use unicode_width::UnicodeWidthStr;
8
9#[derive(Clone, Copy)]
10pub struct StyleConfig {
11    pub filename: Style,
12    pub path: Style,
13    pub dim: Style,
14    pub separator: Style,
15}
16
17impl Default for StyleConfig {
18    fn default() -> Self {
19        Self {
20            filename: Style::default().add_modifier(ratatui::style::Modifier::BOLD),
21            path: Style::default(),
22            dim: Style::default().add_modifier(ratatui::style::Modifier::DIM),
23            separator: Style::default().add_modifier(ratatui::style::Modifier::DIM),
24        }
25    }
26}
27
28pub fn format_path_line<'a>(path: &'a Path, max_width: usize) -> Line<'a> {
29    let spans = format_path_spans(path, max_width, StyleConfig::default());
30    Line::from(spans)
31}
32
33pub fn format_path_line_with_style<'a>(
34    path: &'a Path,
35    max_width: usize,
36    style: StyleConfig,
37) -> Line<'a> {
38    let spans = format_path_spans(path, max_width, style);
39    Line::from(spans)
40}
41
42/// Backwards-compatible API: default min_tail = 1
43pub fn format_path_spans(path: &Path, max_width: usize, style: StyleConfig) -> Vec<Span<'static>> {
44    format_path_spans_with_min_tail(path, max_width, style, 1)
45}
46
47/// Main formatting entry. `min_tail` is the minimum number of trailing
48/// directory segments to prefer preserving when eliding. Callers that show
49/// flat lists (e.g., recent files) may set `min_tail = 2` to improve context.
50pub fn format_path_spans_with_min_tail(
51    path: &Path,
52    max_width: usize,
53    style: StyleConfig,
54    min_tail: usize,
55) -> Vec<Span<'static>> {
56    let (filename, dirs) = split_path(path);
57
58    let filename_str = os_to_str(filename).into_owned();
59    let file_w = width(&filename_str);
60
61    // If there are no directory components, show filename only (no separator).
62    if dirs.is_empty() {
63        return vec![Span::styled(filename_str, style.filename)];
64    }
65
66    let sep = " — ";
67    let sep_w = width(sep);
68
69    if file_w + sep_w >= max_width {
70        return vec![Span::styled(filename_str, style.filename)];
71    }
72
73    let mut remaining = max_width - file_w - sep_w;
74
75    let mut spans: Vec<Span<'static>> = Vec::with_capacity(8);
76    spans.push(Span::styled(filename_str.clone(), style.filename));
77    spans.push(Span::styled(sep.to_string(), style.separator));
78
79    // 1) Try full path
80    if write_full_owned(&mut spans, &dirs, &mut remaining, &style) {
81        return spans;
82    }
83
84    // 2) If single directory that is too wide, try compressing it directly
85    if dirs.len() == 1 {
86        let s = os_to_str(dirs[0]).into_owned();
87        for level in 1..=3 {
88            let comp = compress_segment(&s, level);
89            if width(&comp) <= remaining {
90                spans.push(Span::styled(comp, style.path));
91                return spans;
92            }
93        }
94    }
95
96    // 3) Compression passes (try shrinking long directory names before eliding)
97    for level in 1..=3 {
98        if write_compressed_elided_owned(&mut spans, &dirs, &mut remaining, level, &style, min_tail) {
99            return spans;
100        }
101    }
102
103    // 4) Middle elision for multi-segment paths
104    if write_middle_elided_owned(&mut spans, &dirs, &mut remaining, &style, min_tail) {
105        return spans;
106    }
107
108    // 5) Minimal fallback: try last segment compressed
109    if let Some(last) = dirs.last()
110        && write_minimal_owned(&mut spans, last, &mut remaining, &style) {
111            return spans;
112        }
113
114    vec![Span::styled(filename_str, style.filename)]
115}
116
117// Owned-span variants of earlier write functions
118fn write_full_owned(
119    spans: &mut Vec<Span<'static>>,
120    dirs: &[&OsStr],
121    remaining: &mut usize,
122    style: &StyleConfig,
123) -> bool {
124    // Pre-check total width required for the full (un-elided) directory list.
125    // This avoids partially writing into `spans` and mutating `remaining` on
126    // failure which would make later fallbacks less likely to succeed.
127    let mut total_needed: usize = 0;
128    for (i, d) in dirs.iter().enumerate() {
129        if i > 0 {
130            total_needed += 1; // '/'
131        }
132        let s = os_to_str(d);
133        total_needed += width(&s);
134    }
135
136    if total_needed > *remaining {
137        return false;
138    }
139
140    // Enough room — delegate to the writer which will succeed.
141    write_dirs_owned(spans, dirs, remaining, style)
142}
143
144fn write_dirs_owned(
145    spans: &mut Vec<Span<'static>>,
146    dirs: &[&OsStr],
147    remaining: &mut usize,
148    style: &StyleConfig,
149) -> bool {
150    let start = spans.len();
151
152    for (i, d) in dirs.iter().enumerate() {
153        if i > 0 {
154            if *remaining < 1 {
155                spans.truncate(start);
156                return false;
157            }
158            spans.push(Span::styled("/".to_string(), style.dim));
159            *remaining -= 1;
160        }
161
162        let s = os_to_str(d);
163        let w = width(&s);
164
165        if w > *remaining {
166            spans.truncate(start);
167            return false;
168        }
169
170        spans.push(Span::styled(s.into_owned(), style.path));
171        *remaining -= w;
172    }
173
174    true
175}
176
177fn write_middle_elided_owned(
178    spans: &mut Vec<Span<'static>>,
179    dirs: &[&OsStr],
180    remaining: &mut usize,
181    style: &StyleConfig,
182    min_tail: usize,
183) -> bool {
184    if dirs.len() < 2 {
185        return write_full_owned(spans, dirs, remaining, style);
186    }
187
188    let start = spans.len();
189    let initial_rem = *remaining;
190
191    // First attempt: preserve first directory if it fits
192    let first = os_to_str(dirs[0]).into_owned();
193    let fw = width(&first);
194
195    if fw <= *remaining {
196        spans.push(Span::styled(first.clone(), style.path));
197        *remaining -= fw;
198
199        let elide_w = width("…/");
200        for tail_len in (1..dirs.len()).rev() {
201            if tail_len < min_tail {
202                continue;
203            }
204
205            let checkpoint_len = spans.len();
206            let checkpoint_rem = *remaining;
207
208            if *remaining < elide_w {
209                spans.truncate(start);
210                *remaining = initial_rem;
211                break;
212            }
213
214            spans.push(Span::styled("…/".to_string(), style.dim));
215            *remaining -= elide_w;
216
217            let tail = &dirs[dirs.len() - tail_len..];
218
219            let mut ok = true;
220            for (i, d) in tail.iter().enumerate() {
221                if i > 0 {
222                    if *remaining < 1 {
223                        ok = false;
224                        break;
225                    }
226                    spans.push(Span::styled("/".to_string(), style.dim));
227                    *remaining -= 1;
228                }
229
230                let s = os_to_str(d).into_owned();
231                let w = width(&s);
232
233                if w > *remaining {
234                    ok = false;
235                    break;
236                }
237
238                spans.push(Span::styled(s, style.path));
239                *remaining -= w;
240            }
241
242            if ok {
243                return true;
244            }
245
246            spans.truncate(checkpoint_len);
247            *remaining = checkpoint_rem;
248        }
249
250        // restore and try without preserving first
251        spans.truncate(start);
252        *remaining = initial_rem;
253    }
254
255    // Second attempt: elide without first segment
256    let elide_w = width("…/");
257    for tail_len in (1..dirs.len()).rev() {
258        if tail_len < min_tail {
259            continue;
260        }
261
262        let checkpoint_len = spans.len();
263        let checkpoint_rem = *remaining;
264
265        if *remaining < elide_w {
266            spans.truncate(start);
267            *remaining = initial_rem;
268            return false;
269        }
270
271        spans.push(Span::styled("…/".to_string(), style.dim));
272        *remaining -= elide_w;
273
274        let tail = &dirs[dirs.len() - tail_len..];
275
276        let mut ok = true;
277        for (i, d) in tail.iter().enumerate() {
278            if i > 0 {
279                if *remaining < 1 {
280                    ok = false;
281                    break;
282                }
283                spans.push(Span::styled("/".to_string(), style.dim));
284                *remaining -= 1;
285            }
286
287            let s = os_to_str(d).into_owned();
288            let w = width(&s);
289
290            if w > *remaining {
291                ok = false;
292                break;
293            }
294
295            spans.push(Span::styled(s, style.path));
296            *remaining -= w;
297        }
298
299        if ok {
300            return true;
301        }
302
303        spans.truncate(checkpoint_len);
304        *remaining = checkpoint_rem;
305    }
306
307    spans.truncate(start);
308    *remaining = initial_rem;
309    false
310}
311
312fn write_compressed_elided_owned(
313    spans: &mut Vec<Span<'static>>,
314    dirs: &[&OsStr],
315    remaining: &mut usize,
316    level: usize,
317    style: &StyleConfig,
318    min_tail: usize,
319) -> bool {
320    let mut compressed: Vec<String> = Vec::with_capacity(dirs.len());
321
322    for d in dirs {
323        let s = os_to_str(d).into_owned();
324        compressed.push(compress_segment(&s, level));
325    }
326
327    write_middle_elided_strs_owned(spans, &compressed, remaining, style, min_tail)
328}
329
330fn write_middle_elided_strs_owned(
331    spans: &mut Vec<Span<'static>>,
332    dirs: &[String],
333    remaining: &mut usize,
334    style: &StyleConfig,
335    min_tail: usize,
336) -> bool {
337    if dirs.len() < 2 {
338        return false;
339    }
340
341    let start = spans.len();
342    let initial_rem = *remaining;
343
344    // Try preserving the first segment
345    let first = &dirs[0];
346    let fw = width(first);
347
348    if fw <= *remaining {
349        spans.push(Span::styled(first.clone(), style.path));
350        *remaining -= fw;
351
352        let elide_w = width("…/");
353        for tail_len in (1..dirs.len()).rev() {
354            if tail_len < min_tail {
355                continue;
356            }
357
358            let checkpoint_len = spans.len();
359            let checkpoint_rem = *remaining;
360
361            if *remaining < elide_w {
362                spans.truncate(start);
363                *remaining = initial_rem;
364                break;
365            }
366
367            spans.push(Span::styled("…/".to_string(), style.dim));
368            *remaining -= elide_w;
369
370            let tail = &dirs[dirs.len() - tail_len..];
371
372            let mut ok = true;
373            for (i, d) in tail.iter().enumerate() {
374                if i > 0 {
375                    if *remaining < 1 {
376                        ok = false;
377                        break;
378                    }
379                    spans.push(Span::styled("/".to_string(), style.dim));
380                    *remaining -= 1;
381                }
382
383                let w = width(d);
384
385                if w > *remaining {
386                    ok = false;
387                    break;
388                }
389
390                spans.push(Span::styled(d.clone(), style.path));
391                *remaining -= w;
392            }
393
394            if ok {
395                return true;
396            }
397
398            spans.truncate(checkpoint_len);
399            *remaining = checkpoint_rem;
400        }
401
402        spans.truncate(start);
403        *remaining = initial_rem;
404    }
405
406    // Try eliding without the first segment
407    let elide_w = width("…/");
408    for tail_len in (1..dirs.len()).rev() {
409        if tail_len < min_tail {
410            continue;
411        }
412
413        let checkpoint_len = spans.len();
414        let checkpoint_rem = *remaining;
415
416        if *remaining < elide_w {
417            spans.truncate(start);
418            *remaining = initial_rem;
419            return false;
420        }
421
422        spans.push(Span::styled("…/".to_string(), style.dim));
423        *remaining -= elide_w;
424
425        let tail = &dirs[dirs.len() - tail_len..];
426
427        let mut ok = true;
428        for (i, d) in tail.iter().enumerate() {
429            if i > 0 {
430                if *remaining < 1 {
431                    ok = false;
432                    break;
433                }
434                spans.push(Span::styled("/".to_string(), style.dim));
435                *remaining -= 1;
436            }
437
438            let w = width(d);
439
440            if w > *remaining {
441                ok = false;
442                break;
443            }
444
445            spans.push(Span::styled(d.clone(), style.path));
446            *remaining -= w;
447        }
448
449        if ok {
450            return true;
451        }
452
453        spans.truncate(checkpoint_len);
454        *remaining = checkpoint_rem;
455    }
456
457    spans.truncate(start);
458    *remaining = initial_rem;
459    false
460}
461
462fn write_minimal_owned(
463    spans: &mut Vec<Span<'static>>,
464    last: &OsStr,
465    remaining: &mut usize,
466    style: &StyleConfig,
467) -> bool {
468    let s = os_to_str(last).into_owned();
469
470    if width("…/") + width(&s) > *remaining {
471        // try compressed variants of the last segment as a final attempt
472        for level in 1..=3 {
473            let comp = compress_segment(&s, level);
474            if width(&comp) <= *remaining {
475                spans.push(Span::styled("…/".to_string(), style.dim));
476                spans.push(Span::styled(comp, style.path));
477                return true;
478            }
479        }
480        return false;
481    }
482
483    spans.push(Span::styled("…/".to_string(), style.dim));
484    spans.push(Span::styled(s, style.path));
485
486    true
487}
488
489fn split_path(path: &Path) -> (&OsStr, Vec<&OsStr>) {
490    let filename = path.file_name().unwrap_or_else(|| "".as_ref());
491
492    let mut dirs = Vec::new();
493    if let Some(parent) = path.parent() {
494        for comp in parent.components() {
495            if let Component::Normal(os) = comp {
496                dirs.push(os);
497            }
498        }
499    }
500    (filename, dirs)
501}
502
503fn os_to_str(os: &OsStr) -> Cow<'_, str> {
504    os.to_string_lossy()
505}
506
507pub fn width(s: &str) -> usize {
508    UnicodeWidthStr::width(s)
509}
510
511fn compress_segment(seg: &str, level: usize) -> String {
512    match level {
513        1 => truncate(seg, 8),
514        2 => truncate(seg, 5),
515        _ => initialism(seg),
516    }
517}
518
519fn truncate(seg: &str, max_len: usize) -> String {
520    if seg.chars().count() <= max_len {
521        return seg.to_string();
522    }
523
524    let mut out = String::with_capacity(max_len);
525
526    for (i, ch) in seg.chars().enumerate() {
527        if i >= max_len - 1 {
528            break;
529        }
530        out.push(ch);
531    }
532
533    out.push('…');
534    out
535}
536
537fn initialism(seg: &str) -> String {
538    let mut out = String::new();
539
540    for part in seg.split(['_', '-', '.']) {
541        if let Some(c) = part.chars().next() {
542            out.push(c);
543        }
544    }
545
546    if out.is_empty() {
547        seg.chars().next().unwrap_or('?').to_string()
548    } else {
549        out
550    }
551}
552
553#[cfg(test)]
554mod tests {
555    use super::*;
556    use std::path::Path;
557    use ratatui::style::Style;
558
559    /// Convenience: default style config for tests
560    fn default_cfg() -> StyleConfig {
561        StyleConfig {
562            filename: Style::default(),
563            path: Style::default(),
564            dim: Style::default(),
565            separator: Style::default(),
566        }
567    }
568
569    /// Convert a Line into a simple String for assertions
570    fn line_to_string(line: &ratatui::text::Line) -> String {
571        // ratatui::text::Line has `spans` field holding Vec<Span>
572        line.spans.iter().map(|span| span.content.clone()).collect()
573    }
574
575    // ---------- Root-level files ----------
576    #[test]
577    fn test_root_file() {
578        let path = Path::new("main.rs");
579        let line = format_path_line(path, 40);
580        assert_eq!(line_to_string(&line), "main.rs");
581    }
582
583    #[test]
584    fn test_root_file_with_label() {
585        let path = Path::new("Cargo.toml");
586        let cfg = default_cfg();
587        let mut spans = format_path_spans(path, 20, cfg);
588        spans.push(ratatui::text::Span::raw(" (unstaged)"));
589        let s: String = spans.iter().map(|sp| sp.content.clone()).collect();
590        assert_eq!(s, "Cargo.toml (unstaged)");
591    }
592
593    // ---------- Single-directory files ----------
594    #[test]
595    fn test_single_directory_short() {
596        let path = Path::new("src/main.rs");
597        let line = format_path_line(path, 40);
598        let s = line_to_string(&line);
599        assert!(s.starts_with("main.rs"));
600        assert!(s.contains("src"));
601    }
602
603    #[test]
604    fn test_single_long_directory_elision() {
605        let path = Path::new("very_long_directory_name/main.rs");
606        let line = format_path_line(path, 15);
607        let s = line_to_string(&line);
608        assert!(s.starts_with("main.rs"));
609        assert!(s.contains("…") || s.contains("vldn") || s.contains("very_lo…"));
610        assert!(width(&s) <= 15);
611    }
612
613    // ---------- Multiple directories ----------
614    #[test]
615    fn test_multiple_directories_wide() {
616        let path = Path::new("src/compiler/parser/ast/main.rs");
617        let line = format_path_line(path, 80);
618        let s = line_to_string(&line);
619        assert!(s.starts_with("main.rs"));
620        assert!(s.contains("src/compiler/parser/ast"));
621    }
622
623    #[test]
624    fn test_multiple_directories_medium() {
625        let path = Path::new("src/compiler/parser/ast/main.rs");
626        let line = format_path_line(path, 30);
627        let s = line_to_string(&line);
628        assert!(s.starts_with("main.rs"));
629        assert!(s.contains("…"));
630    }
631
632    #[test]
633    fn test_multiple_directories_narrow() {
634        let path = Path::new("src/compiler/parser/ast/main.rs");
635        let line = format_path_line(path, 15);
636        let s = line_to_string(&line);
637        assert!(s.starts_with("main.rs"));
638        assert!(s.contains("…"));
639    }
640
641    #[test]
642    fn test_smart_directory_compression_multiple_dirs() {
643        // Deep path with one very long directory
644        let path = Path::new("src/compiler/very_long_directory_name/parser/ast/main.rs");
645        let line = format_path_line(path, 30);
646        let s = line_to_string(&line);
647        assert!(s.starts_with("main.rs"));
648        assert!(s.contains("…") || s.contains("vldn") || s.contains("very_lo…"));
649        assert!(width(&s) <= 30);
650    }
651
652    // ---------- Narrow width edge ----------
653    #[test]
654    fn test_filename_only_when_too_narrow() {
655        let path = Path::new("src/compiler/parser/ast/main.rs");
656        let line = format_path_line(path, 5);
657        let s = line_to_string(&line);
658        assert_eq!(s, "main.rs");
659    }
660
661    // ---------- Unicode filenames ----------
662    #[test]
663    fn test_unicode_filename() {
664        let path = Path::new("src/解析/メイン.rs");
665        let line = format_path_line(path, 40);
666        let s = line_to_string(&line);
667        assert!(s.contains("メイン.rs"));
668        assert!(s.contains("解析") || s.contains("…"));
669    }
670
671    // ---------- Labels / decorations ----------
672    #[test]
673    fn test_path_with_label_simulation() {
674        let path = Path::new("src/compiler/parser/main.rs");
675        let cfg = default_cfg();
676        let mut spans = format_path_spans(path, 20, cfg);
677        spans.push(ratatui::text::Span::raw(" (staged)"));
678        let s: String = spans.iter().map(|sp| sp.content.clone()).collect();
679        assert!(s.starts_with("main.rs"));
680        assert!(s.contains("…") || s.contains("parser"));
681        assert!(s.contains("(staged)"));
682    }
683}