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