Skip to main content

oo_ide/widgets/
terminal.rs

1//! Widget that renders a `vt100::Parser`'s screen into a Ratatui buffer.
2//!
3//! Renders the visible terminal content, including scrollback history when
4//! the scroll offset is non-zero. The `screen.cell(row, col)` method respects
5//! the scrollback offset set on the parser.
6
7use std::cell::Ref;
8
9use ratatui::{
10    buffer::Buffer,
11    layout::Rect,
12    style::{Color, Modifier, Style},
13    widgets::Widget,
14};
15
16#[derive(Debug, Clone)]
17pub enum LinkKind {
18    Url(String),
19    File { path: std::path::PathBuf, line: Option<usize>, column: Option<usize> },
20    /// A diagnostic match (error/warning/note) detected in a terminal row.
21    /// Used for row-level background severity indicators. The link spans the
22    /// whole row so individual `File` links within it take click priority.
23    Diagnostic {
24        path: Option<std::path::PathBuf>,
25        line: Option<usize>,
26        column: Option<usize>,
27        severity: crate::issue_registry::Severity,
28        message: String,
29    },
30    /// A search match highlighted by the terminal search UI.
31    Search,
32    /// The currently active/currently-selected search match. Rendered with a
33    /// stronger highlight so it's easy to locate.
34    SearchCurrent,
35}
36
37#[derive(Debug, Clone)]
38pub struct Link {
39    pub kind: LinkKind,
40    pub row: u16,
41    pub start_col: u16,
42    pub end_col: u16,
43    pub text: String,
44}
45
46pub struct TerminalWidget<'a> {
47    pub parser: Ref<'a, vt100::Parser>,
48    pub links: Vec<Link>,
49    /// When true, Url/File links are rendered with underlines and colour. Set
50    /// to true when Ctrl is held so users can Ctrl+Click to open links.
51    pub show_links: bool,
52}
53
54impl<'a> Widget for TerminalWidget<'a> {
55    fn render(self, area: Rect, buf: &mut Buffer) {
56        if area.height == 0 || area.width == 0 {
57            return;
58        }
59
60        let screen = self.parser.screen();
61        // The app now explicitly splits the layout and passes a reduced `area`
62        // (terminal area excluding the status bar). Draw into the full provided
63        // area rather than reserving an extra row.
64        let draw_rows = area.height;
65        let cols = area.width;
66
67        if draw_rows == 0 || cols == 0 {
68            return;
69        }
70
71        // screen.cell(row, col) respects scrollback offset
72        for row in 0..draw_rows {
73            for col in 0..cols {
74                let x = area.left() + col;
75                let y = area.top() + row;
76
77                if let Some(cell_ref) = screen.cell(row, col) {
78                    let contents = cell_ref.contents();
79                    let ch = if contents.is_empty() {
80                        ' '
81                    } else {
82                        contents.chars().next().unwrap_or(' ')
83                    };
84                    let mut style = build_style(cell_ref);
85
86                    // Highlight links: underline + light blue fg for Url/File;
87                    // severity-tinted background for Diagnostic rows.
88                    // Two-pass: Diagnostic bg first (lower priority), then
89                    // Url/File style on top (higher priority / break).
90                    for link in &self.links {
91                        if link.row == row
92                            && col >= link.start_col
93                            && col < link.end_col
94                            && let LinkKind::Diagnostic { severity, .. } = &link.kind
95                        {
96                            use crate::issue_registry::Severity;
97                            let bg = match severity {
98                                Severity::Error => Color::Rgb(55, 18, 18),
99                                Severity::Warning => Color::Rgb(50, 40, 8),
100                                _ => Color::Rgb(12, 32, 48),
101                            };
102                            style = style.bg(bg);
103                        }
104                    }
105                    if self.show_links {
106                        for link in &self.links {
107                            if link.row == row && col >= link.start_col && col < link.end_col {
108                                match &link.kind {
109                                    LinkKind::Url(_) | LinkKind::File { .. } => {
110                                        style = style
111                                            .fg(Color::LightBlue)
112                                            .add_modifier(Modifier::UNDERLINED);
113                                        break;
114                                    }
115                                    LinkKind::Diagnostic { .. } => {} // handled above
116                                    LinkKind::Search | LinkKind::SearchCurrent => {}
117                                }
118                            }
119                        }
120                    }
121
122                    // Third pass: apply non-color search highlight (bg + bold) so it's
123                    // visible in high-contrast modes. This runs after Url/File so
124                    // Underlined/LightBlue stays visible on top. Current matches get a
125                    // stronger amber-like highlight to make them stand out.
126                    for link in &self.links {
127                        if link.row == row && col >= link.start_col && col < link.end_col {
128                            match &link.kind {
129                                LinkKind::SearchCurrent => {
130                                    style = style.add_modifier(Modifier::BOLD).bg(Color::Rgb(170, 110, 30)).fg(Color::Black);
131                                }
132                                LinkKind::Search => {
133                                    style = style.add_modifier(Modifier::BOLD).bg(Color::Rgb(30, 48, 70));
134                                }
135                                _ => {}
136                            }
137                        }
138                    }
139
140                    if let Some(buf_cell) = buf.cell_mut((x, y)) {
141                        buf_cell.set_char(ch);
142                        buf_cell.set_style(style);
143                    }
144                } else if let Some(buf_cell) = buf.cell_mut((x, y)) {
145                    buf_cell.set_char(' ');
146                    buf_cell.set_style(Style::default());
147                }
148            }
149        }
150
151        // Render the cursor (only in live view, not when scrolling)
152        if screen.scrollback() == 0 {
153            let (crow, ccol) = screen.cursor_position();
154            if crow < draw_rows && ccol < cols {
155                let cx = area.left() + ccol;
156                let cy = area.top() + crow;
157                if let Some(buf_cell) = buf.cell_mut((cx, cy)) {
158                    let existing = buf_cell.style();
159                    buf_cell.set_style(existing.add_modifier(Modifier::REVERSED));
160                }
161            }
162        }
163    }
164}
165
166fn build_style(cell: &vt100::Cell) -> Style {
167    let mut style = Style::default()
168        .fg(map_color(cell.fgcolor()))
169        .bg(map_color(cell.bgcolor()));
170
171    if cell.bold() {
172        style = style.add_modifier(Modifier::BOLD);
173    }
174    if cell.italic() {
175        style = style.add_modifier(Modifier::ITALIC);
176    }
177    if cell.underline() {
178        style = style.add_modifier(Modifier::UNDERLINED);
179    }
180    if cell.inverse() {
181        style = style.add_modifier(Modifier::REVERSED);
182    }
183
184    style
185}
186
187fn map_color(c: vt100::Color) -> Color {
188    match c {
189        vt100::Color::Default => Color::Reset,
190        vt100::Color::Idx(i) => match i {
191            0 => Color::Black,
192            1 => Color::Red,
193            2 => Color::Green,
194            3 => Color::Yellow,
195            4 => Color::Blue,
196            5 => Color::Magenta,
197            6 => Color::Cyan,
198            7 => Color::Gray,
199            8 => Color::DarkGray,
200            9 => Color::LightRed,
201            10 => Color::LightGreen,
202            11 => Color::LightYellow,
203            12 => Color::LightBlue,
204            13 => Color::LightMagenta,
205            14 => Color::LightCyan,
206            15 => Color::White,
207            n => Color::Indexed(n),
208        },
209        vt100::Color::Rgb(r, g, b) => Color::Rgb(r, g, b),
210    }
211}
212
213// Link detection helper: extracts Link structs from a parser's visible screen
214// relative to the given current working directory. Kept crate-visible for
215// tests and cross-module use.
216//
217// Uses the background SharedRegistry for fast suffix lookups when available.
218static GLOBAL_REGISTRY: once_cell::sync::Lazy<std::sync::Mutex<Option<crate::file_index::SharedRegistry>>> =
219    once_cell::sync::Lazy::new(|| std::sync::Mutex::new(None));
220
221// Small resolved-path cache to avoid repeated index/walk searches for the
222// same printed token. Stores (timestamp, path). TTL-based eviction applied on access.
223static RESOLVE_CACHE: once_cell::sync::Lazy<std::sync::Mutex<std::collections::HashMap<String, (std::time::SystemTime, std::path::PathBuf)>>> =
224    once_cell::sync::Lazy::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
225
226const RESOLVE_TTL: std::time::Duration = std::time::Duration::from_secs(30);
227const RESOLVE_CAP: usize = 4096;
228
229pub(crate) fn set_global_registry(idx: crate::file_index::SharedRegistry) {
230    let mut g = GLOBAL_REGISTRY.lock().unwrap();
231    *g = Some(idx);
232}
233
234pub(crate) fn detect_links_from_screen(parser: &vt100::Parser, cwd: &std::path::Path) -> Vec<Link> {
235    use std::collections::BTreeMap;
236
237    const TRIM_CHARS: &[char] = &['(', ')', '[', ']', '{', '}', '.', ',', ';', '"', '\'', '<', '>'];
238
239    let mut out: Vec<Link> = Vec::new();
240    let screen = parser.screen();
241    let (rows_u16, cols_u16) = screen.size();
242    let rows = rows_u16 as usize;
243    let cols = cols_u16 as usize;
244
245
246    let mut searcher = crate::file_index::NucleoSearch::new();
247
248
249        let mut process_chars = |chars: &[char], positions: &[(usize, usize)], cwd: &std::path::Path, out: &mut Vec<Link>| {
250        if chars.is_empty() {
251            return;
252        }
253        // Build pairs of (char, pos) and filter out placeholder markers ('\0')
254        // which represent wide-character continuation columns. Keep positions so
255        // start/end columns reflect actual screen columns.
256        let pairs: Vec<(char, (usize, usize))> = chars.iter().cloned().zip(positions.iter().cloned()).collect();
257        let filtered_pairs: Vec<(char, (usize, usize))> = pairs.into_iter().filter(|(ch, _)| *ch != '\0').collect();
258        if filtered_pairs.is_empty() {
259            return;
260        }
261
262        // Trim leading/trailing punctuation from the visible characters
263        let mut s = 0usize;
264        let mut e = filtered_pairs.len();
265        while s < e && TRIM_CHARS.contains(&filtered_pairs[s].0) {
266            s += 1;
267        }
268        while s < e && TRIM_CHARS.contains(&filtered_pairs[e - 1].0) {
269            e -= 1;
270        }
271        if s >= e {
272            return;
273        }
274
275        let core_chars: Vec<char> = filtered_pairs[s..e].iter().map(|(c, _)| *c).collect();
276        let core: String = core_chars.iter().collect();
277        let core_clean: String = core.chars().filter(|c| !c.is_control()).collect();
278        let trimmed_positions: Vec<(usize, usize)> = filtered_pairs[s..e].iter().map(|(_, p)| *p).collect();
279
280        // Helper to split token positions by row into start/end columns.
281        let mut by_row: BTreeMap<usize, (usize, usize)> = BTreeMap::new();
282        for &(r, c) in &trimmed_positions {
283            by_row
284                .entry(r)
285                .and_modify(|e| {
286                    if c < e.0 {
287                        e.0 = c;
288                    }
289                    if c > e.1 {
290                        e.1 = c;
291                    }
292                })
293                .or_insert((c, c));
294        }
295
296        if core_clean.starts_with("http://") || core_clean.starts_with("https://") {
297            for (r, (start_c, end_c)) in by_row {
298                out.push(Link {
299                    kind: LinkKind::Url(core_clean.clone()),
300                    row: r as u16,
301                    start_col: start_c as u16,
302                    end_col: (end_c + 1) as u16,
303                    text: core_clean.clone(),
304                });
305            }
306            return;
307        }
308
309        if core_clean.contains('/') || core_clean.contains('\\') || core_clean.starts_with('.') || core_clean.contains('.') {
310            let parts: Vec<&str> = core_clean.split(':').collect();
311            let mut end_i = parts.len();
312            let mut column = None;
313            let mut line = None;
314            // Discard trailing empty segments (e.g. trailing colon in "path:3:1:").
315            while end_i > 0 && parts[end_i - 1].is_empty() {
316                end_i -= 1;
317            }
318            if end_i >= 2 && parts[end_i - 1].chars().all(|c| c.is_ascii_digit()) {
319                column = parts[end_i - 1].parse::<usize>().ok();
320                end_i -= 1;
321            }
322            if end_i >= 2 && parts[end_i - 1].chars().all(|c| c.is_ascii_digit()) {
323                line = parts[end_i - 1].parse::<usize>().ok();
324                end_i -= 1;
325            }
326            let base = parts[..end_i].join(":");
327            let candidate = if std::path::Path::new(&base).is_absolute() {
328                std::path::PathBuf::from(&base)
329            } else {
330                cwd.join(&base)
331            };
332
333            // Resolve candidate: if it exists as-is, use it. Otherwise try to
334            // find a matching file under `cwd` by suffix (handles cases like
335            // "/tmp/whatever/src/lib.rs" mapping to "src/lib.rs" in project).
336            let resolved_path: Option<std::path::PathBuf> = if candidate.is_file() {
337                // Prefer canonicalized absolute path when possible, but strip the
338                // Windows extended path prefix (\\?\) which `canonicalize` may
339                // produce to keep equality comparisons consistent with tests.
340                std::fs::canonicalize(&candidate).ok().map(|c| {
341                    let s = c.to_string_lossy();
342                    if let Some(stripped) = s.strip_prefix("\\\\?\\") {
343                        std::path::PathBuf::from(stripped)
344                    } else {
345                        c
346                    }
347                }).or(Some(candidate.clone()))
348            } else {
349                let base_path = std::path::Path::new(&base);
350                let comps: Vec<std::ffi::OsString> = base_path.iter().map(|s| s.to_os_string()).collect();
351                let mut found: Option<std::path::PathBuf> = None;
352                // Prefer the longest suffix (most specific) first.
353                for suffix_len in (1..=comps.len()).rev() {
354                    let start = comps.len().saturating_sub(suffix_len);
355                    let mut suffix = std::path::PathBuf::new();
356                    for c in &comps[start..] {
357                        suffix.push(c);
358                    }
359                    let mut matches: Vec<std::path::PathBuf> = Vec::new();
360                    // Quick local check: if cwd/suffix exists, prefer it (cheap filesystem call).
361                    let local_candidate = cwd.join(&suffix);
362                    if local_candidate.is_file() {
363                        matches.push(local_candidate);
364                    }
365                    // Check small global resolve cache first to avoid repeated searches for the same token
366                    let cache_key = suffix.to_string_lossy().replace('\\', "/").to_lowercase();
367                    {
368                        let mut cache = RESOLVE_CACHE.lock().unwrap();
369                        if let Some((ts, p)) = cache.get(&cache_key) {
370                            if ts.elapsed().unwrap_or(std::time::Duration::from_secs(u64::MAX)) < RESOLVE_TTL {
371                                matches.push(p.clone());
372                            } else {
373                                cache.remove(&cache_key);
374                            }
375                        }
376                    }
377                    // Use the SharedRegistry + NucleoSearch when available for fast suffix matching.
378                    if matches.is_empty()
379                        && let Some(shared_idx) = GLOBAL_REGISTRY.lock().unwrap().as_ref() {
380                            let arc = shared_idx.load();
381                            if let Some(reg) = (**arc).as_ref() {
382                                // Normalize suffix for comparison
383                                let suffix_str = suffix.to_string_lossy().replace('\\', "/").to_lowercase();
384                                // Ask for up to 64 candidates from the index (bounded work)
385                                let results = searcher.search_top(reg.file_index(), &suffix_str, 64);
386                                for entry in results {
387                                    let entry_str = entry.path.to_string_lossy().replace('\\', "/").to_lowercase();
388                                    if entry_str.ends_with(&suffix_str) {
389                                        matches.push(cwd.join(&entry.path));
390                                    }
391                                }
392                            }
393                        }
394
395                    // WalkDir fallback removed: rely on SharedRegistry for suffix resolution.
396
397                    if !matches.is_empty() {
398                        // Score: prefer smallest relative depth under cwd (path closest to project root),
399                        // then shortest absolute path (fewer components).
400                        matches.sort_by(|a, b| {
401                            let a_rel = a.strip_prefix(cwd).ok().map(|rp| rp.components().count()).unwrap_or(usize::MAX);
402                            let b_rel = b.strip_prefix(cwd).ok().map(|rp| rp.components().count()).unwrap_or(usize::MAX);
403                            if a_rel != b_rel { return a_rel.cmp(&b_rel); }
404                            let a_abs = a.components().count();
405                            let b_abs = b.components().count();
406                            if a_abs != b_abs { return a_abs.cmp(&b_abs); }
407                            // fallback to lexical order to keep deterministic behavior
408                            a.cmp(b)
409                        });
410                        // Canonicalize chosen match if possible so editor path comparisons line up.
411                        let chosen = matches.remove(0);
412                        let chosen_canon = std::fs::canonicalize(&chosen).map(|c| {
413                            let s = c.to_string_lossy();
414                            if let Some(stripped) = s.strip_prefix("\\\\?\\") {
415                                std::path::PathBuf::from(stripped)
416                            } else {
417                                c
418                            }
419                        }).unwrap_or(chosen);
420                        found = Some(chosen_canon.clone());
421                        // cache the chosen resolution for this suffix to speed future lookups
422                        let mut cache = RESOLVE_CACHE.lock().unwrap();
423                        if cache.len() > RESOLVE_CAP {
424                            // prune old entries
425                            cache.retain(|_, (t, _)| t.elapsed().unwrap_or(std::time::Duration::from_secs(u64::MAX)) < RESOLVE_TTL);
426                            if cache.len() > RESOLVE_CAP {
427                                // drop half to keep memory bounded
428                                let keys: Vec<String> = cache.keys().take(cache.len() / 2).cloned().collect();
429                                for k in keys { cache.remove(&k); }
430                            }
431                        }
432                        cache.insert(cache_key.clone(), (std::time::SystemTime::now(), chosen_canon.clone()));
433                        break;
434                    }
435                }
436                found
437            };
438
439            if let Some(resolved) = resolved_path {
440                for (r, (start_c, end_c)) in by_row {
441                    out.push(Link {
442                        kind: LinkKind::File { path: resolved.clone(), line, column },
443                        row: r as u16,
444                        start_col: start_c as u16,
445                        end_col: (end_c + 1) as u16,
446                        text: core_clean.clone(),
447                    });
448                }
449            }
450        }
451        };
452
453    let mut pending_chars: Vec<char> = Vec::new();
454    let mut pending_pos: Vec<(usize, usize)> = Vec::new();
455
456    for r in 0..rows {
457        for c in 0..cols {
458            let r_u16 = r as u16;
459            let c_u16 = c as u16;
460            let ch = if let Some(cell_ref) = screen.cell(r_u16, c_u16) {
461                // If this column is the second half of a wide char, treat it as a
462                // placeholder so the token continues but no visible character is emitted.
463                if cell_ref.is_wide_continuation() {
464                    '\0'
465                } else if cell_ref.has_contents() {
466                    cell_ref.contents().chars().next().unwrap_or(' ')
467                } else {
468                    // truly empty cell -> whitespace (breaks tokens)
469                    ' '
470                }
471            } else {
472                ' '
473            };
474
475            // Treat placeholder ('\0') as non-whitespace so wide continuations don't break tokens.
476            if ch.is_whitespace() {
477                if !pending_chars.is_empty() {
478                    process_chars(&pending_chars, &pending_pos, cwd, &mut out);
479                    pending_chars.clear();
480                    pending_pos.clear();
481                }
482            } else {
483                pending_chars.push(ch);
484                pending_pos.push((r, c));
485            }
486        }
487    }
488
489    if !pending_chars.is_empty() {
490        process_chars(&pending_chars, &pending_pos, cwd, &mut out);
491    }
492
493    // Per-row diagnostic detection: scan full row text for GNU-style error/warning
494    // patterns and create Diagnostic links spanning the whole row. These are appended
495    // AFTER Url/File links so the latter take click priority (smaller span wins).
496    {
497        use crate::diagnostics_extractor::DiagnosticsExtractor;
498        static ROW_EXTRACTOR: once_cell::sync::Lazy<DiagnosticsExtractor> =
499            once_cell::sync::Lazy::new(|| {
500                DiagnosticsExtractor::new("terminal:visual", "terminal")
501            });
502        for r in 0..rows {
503            let mut row_text = String::with_capacity(cols);
504            for c in 0..cols {
505                if let Some(cell) = screen.cell(r as u16, c as u16) {
506                    let contents = cell.contents();
507                    if contents.is_empty() {
508                        row_text.push(' ');
509                    } else {
510                        row_text.push_str(contents);
511                    }
512                } else {
513                    row_text.push(' ');
514                }
515            }
516            for issue in ROW_EXTRACTOR.extract_from_str(&row_text) {
517                out.push(Link {
518                    kind: LinkKind::Diagnostic {
519                        path: issue.path,
520                        line: issue.range.map(|(s, _)| s.line + 1),
521                        column: issue.range.map(|(s, _)| s.column + 1),
522                        severity: issue.severity,
523                        message: issue.message,
524                    },
525                    row: r as u16,
526                    start_col: 0,
527                    end_col: cols as u16,
528                    text: row_text.trim_end().to_string(),
529                });
530            }
531        }
532    }
533
534    out
535}
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540    use tempfile::tempdir;
541    use vt100::Parser;
542
543    #[test]
544    fn detect_url() {
545        let mut parser = Parser::new(10, 80, 100);
546        parser.process(b"http://example.com\n");
547        let cwd = std::path::Path::new(".");
548        let links = detect_links_from_screen(&parser, cwd);
549        assert_eq!(links.len(), 1);
550        match &links[0].kind {
551            LinkKind::Url(u) => assert_eq!(u, "http://example.com"),
552            _ => panic!("expected url link"),
553        }
554    }
555
556    #[test]
557    fn detect_wrapped_url_across_two_lines() {
558        // Use a width that ensures the URL is visible (vt100 behavior varies by width).
559        let mut parser = Parser::new(2, 18, 100);
560        parser.process(b"http://example.com\n");
561        let cwd = std::path::Path::new(".");
562        let links = detect_links_from_screen(&parser, cwd);
563        assert!(links.iter().any(|l| matches!(&l.kind, LinkKind::Url(u) if u == "http://example.com")));
564    }
565
566    #[test]
567    fn detect_wrapped_file_with_line_col() {
568        // Create a deep path so it wraps across the visible columns and ensure
569        // the detector still recognizes the file:line:col pattern.
570        let dir = tempdir().unwrap();
571        let subdir = dir.path().join("a").join("b").join("c");
572        std::fs::create_dir_all(&subdir).unwrap();
573        let pfile = subdir.join("long_filename_example.rs");
574        std::fs::write(&pfile, "fn main() {}\n").unwrap();
575
576        // Build the printed text and choose a width that ensures a two-line wrap.
577        // Use 3 rows so the trailing newline goes to row 2, avoiding a scroll that
578        // would push the first wrapped row into scrollback.
579        let text = format!("{}:12:3\n", pfile.display());
580        let visible_len = text.chars().count();
581        let cols = (visible_len / 2) + 1; // force wrap into two rows
582        let mut parser = Parser::new(3, cols.try_into().unwrap(), 100);
583        parser.process(text.as_bytes());
584
585        let links = detect_links_from_screen(&parser, dir.path());
586        assert!(links.iter().any(|l| matches!(&l.kind, LinkKind::File { path, line, column } if path == &pfile && line == &Some(12) && column == &Some(3))));
587    }
588
589    #[test]
590    fn detect_url_with_ansi_wrapped() {
591        // Colorized URL that wraps across two rows; detector should ignore ANSI and find URL.
592        // Use 3 rows so the trailing newline goes to row 2, preventing a scroll that would
593        // push the first wrapped row into scrollback.
594        let url = "http://wrapped.example.com";
595        let visible_len = url.chars().count();
596        let cols = (visible_len / 2) + 1; // force wrap into two rows
597        let mut parser = Parser::new(3, cols.try_into().unwrap(), 100);
598        parser.process(format!("\x1b[31m{}\x1b[0m\n", url).as_bytes());
599
600        let links = detect_links_from_screen(&parser, std::path::Path::new("."));
601        assert!(links.iter().any(|l| matches!(&l.kind, LinkKind::Url(u) if u == url)));
602    }
603
604    #[test]
605    fn detect_file_resolve_non_exact() {
606        // Simulate a terminal line that contains an absolute path from elsewhere,
607        // but the same filename exists under the project's cwd. Detector should
608        // resolve to the local file when possible.
609        let dir = tempdir().unwrap();
610        let project = dir.path().join("project");
611        let src = project.join("src");
612        std::fs::create_dir_all(&src).unwrap();
613        let local = src.join("lib.rs");
614        std::fs::write(&local, "fn main() {}\n").unwrap();
615
616        // Construct a fake absolute path outside the cwd that ends with src/lib.rs
617        let fake = std::path::Path::new("/tmp/other").join("project").join("src").join("lib.rs");
618        let text = format!("{}:12:3\n", fake.display());
619
620        let mut parser = Parser::new(10, 80, 100);
621        parser.process(text.as_bytes());
622
623        let links = detect_links_from_screen(&parser, project.as_path());
624        assert!(links.iter().any(|l| matches!(&l.kind, LinkKind::File { path, line, column } if path == &local && line == &Some(12) && column == &Some(3))));
625    }
626
627    #[test]
628    fn detect_file_with_line_col() {
629        let dir = tempdir().unwrap();
630        let pfile = dir.path().join("foo.rs");
631        std::fs::write(&pfile, "fn main() {}\n").unwrap();
632        let mut parser = Parser::new(10, 80, 100);
633        parser.process(b"foo.rs:12:3\n");
634        let links = detect_links_from_screen(&parser, dir.path());
635        assert_eq!(links.len(), 1);
636        match &links[0].kind {
637            LinkKind::File { path, line, column } => {
638                assert_eq!(path, &pfile);
639                assert_eq!(line, &Some(12));
640                assert_eq!(column, &Some(3));
641            }
642            _ => panic!("expected file link"),
643        }
644    }
645
646    #[test]
647    fn skip_directory() {
648        let dir = tempdir().unwrap();
649        std::fs::create_dir(dir.path().join("somedir")).unwrap();
650        let mut parser = Parser::new(10, 80, 100);
651        parser.process(b"./somedir\n");
652        let links = detect_links_from_screen(&parser, dir.path());
653        assert!(links.is_empty());
654    }
655
656    #[test]
657    fn detect_skip_directory_wrapped() {
658        let dir = tempdir().unwrap();
659        let deep = dir.path().join("some").join("very").join("long").join("directory");
660        std::fs::create_dir_all(&deep).unwrap();
661        let mut parser = Parser::new(2, 12, 100);
662        parser.process(format!("{}\n", deep.display()).as_bytes());
663        let links = detect_links_from_screen(&parser, dir.path());
664        assert!(links.is_empty());
665    }
666
667    // ── Diagnostic detection tests ──────────────────────────────────────────
668
669    #[test]
670    fn detect_diagnostic_error_row() {
671        // A GNU-style error line should produce a Diagnostic link spanning the row.
672        let mut parser = Parser::new(10, 80, 100);
673        parser.process(b"src/main.rs:42:10: error: type mismatch\n");
674        let links = detect_links_from_screen(&parser, std::path::Path::new("."));
675        let diag = links.iter().find(|l| matches!(&l.kind, LinkKind::Diagnostic { .. }));
676        assert!(diag.is_some(), "expected a Diagnostic link for error row");
677        if let LinkKind::Diagnostic { severity, message, .. } = &diag.unwrap().kind {
678            assert_eq!(*severity, crate::issue_registry::Severity::Error);
679            assert!(message.contains("type mismatch"), "msg: {message}");
680        }
681    }
682
683    #[test]
684    fn detect_diagnostic_warning_row() {
685        let mut parser = Parser::new(10, 80, 100);
686        parser.process(b"lib/foo.rs:10:5: warning: unused variable\n");
687        let links = detect_links_from_screen(&parser, std::path::Path::new("."));
688        let diag = links.iter().find(|l| matches!(
689            &l.kind,
690            LinkKind::Diagnostic { severity, .. } if *severity == crate::issue_registry::Severity::Warning
691        ));
692        assert!(diag.is_some(), "expected a Warning Diagnostic link");
693    }
694
695    #[test]
696    fn detect_diagnostic_does_not_fire_on_plain_output() {
697        let mut parser = Parser::new(10, 80, 100);
698        parser.process(b"   Compiling mylib v0.1.0\n");
699        let links = detect_links_from_screen(&parser, std::path::Path::new("."));
700        let has_diag = links.iter().any(|l| matches!(&l.kind, LinkKind::Diagnostic { .. }));
701        assert!(!has_diag, "plain compile lines should not produce Diagnostic links");
702    }
703
704    #[test]
705    fn diagnostic_and_file_links_coexist_on_same_row() {
706        // A diagnostic row should have BOTH a File link (for the path token, clickable)
707        // and a Diagnostic link (for the row background indicator).
708        let dir = tempdir().unwrap();
709        let pfile = dir.path().join("foo.rs");
710        std::fs::write(&pfile, "fn main() {}\n").unwrap();
711        let text = format!("{}:3:1: error: undeclared variable\n", pfile.display());
712        let mut parser = Parser::new(10, 120, 100);
713        parser.process(text.as_bytes());
714        let links = detect_links_from_screen(&parser, dir.path());
715        let has_file = links.iter().any(|l| matches!(&l.kind, LinkKind::File { .. }));
716        let has_diag = links.iter().any(|l| matches!(&l.kind, LinkKind::Diagnostic { .. }));
717        assert!(has_file, "expected a File link for the path token");
718        assert!(has_diag, "expected a Diagnostic link for the row background");
719    }
720}