oo-ide 0.0.3

∞ is a terminal IDE focused on low distraction, high usability.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
//! Diagnostics extraction from parsed task output.
//!
//! [`DiagnosticsExtractor`] consumes [`StyledLine`] values produced by the
//! VT100 parser and emits [`NewIssue`] entries that are fed back to
//! [`crate::issue_registry::IssueRegistry`] via `Operation::AddIssue`.
//!
//! # Scope (MVP)
//!
//! Matches two categories of output:
//!
//! 1. **GNU-style diagnostic lines** (Rust, C, Go, most compilers):
//!    ```text
//!    path/to/file.rs:42:10: error: something bad happened
//!    path/to/file.rs:42: warning: something is suspicious
//!    ```
//!
//! 2. **TODO / FIXME / HACK / XXX comments** in any output line:
//!    ```text
//!    // TODO: refactor this function
//!    FIXME: this is broken
//!    ```
//!
//! Unknown or malformed lines are silently ignored — the extractor never
//! panics on bad input.
//!
//! # Usage
//!
//! One `DiagnosticsExtractor` is created per task execution (called from
//! [`crate::task_executor::TaskExecutor::spawn`]).  It is shared across the
//! stdout and stderr streams via [`std::sync::Arc`].
//!
//! ```ignore
//! let extractor = Arc::new(DiagnosticsExtractor::new("task:build:crate:a", "build"));
//! for line in styled_lines {
//!     for issue in extractor.extract_from_line(&line) {
//!         let _ = op_tx.send(vec![Operation::AddIssue { issue }]);
//!     }
//! }
//! ```

use std::path::PathBuf;

use regex::Regex;

use crate::issue_registry::{NewIssue, Severity};
use crate::vt_parser::StyledLine;

// ---------------------------------------------------------------------------
// DiagnosticsExtractor
// ---------------------------------------------------------------------------

/// Stateless, `Send + Sync` diagnostics extractor.
///
/// Each task execution gets one instance (shared between stdout and stderr
/// streams via `Arc`).  All extracted issues receive the same `marker` and
/// `source` so they can be cleared together when the task reruns.
pub struct DiagnosticsExtractor {
    /// Issue registry marker — `"task:{queue}:{target}"`.
    marker: String,
    /// Human-readable source tag for the registry (e.g. `"build"`, `"test"`).
    source: String,
    /// Matches `path:line:col: severity: message` (three-component prefix).
    gnu_3: Regex,
    /// Matches `path:line: severity: message` (two-component prefix, no column).
    gnu_2: Regex,
    /// Matches TODO/FIXME/HACK/XXX annotations anywhere in a line.
    todo_pat: Regex,
    /// Matches a rustc/cargo severity header: `error[E0425]: message` or
    /// `warning: message`.  Group 1 = severity keyword, group 2 = message.
    rustc_header: Regex,
    /// Matches a rustc/cargo source location line: ` --> path:line:col`.
    /// Group 1 = path, group 2 = line, group 3 = col.
    rustc_arrow: Regex,
}

impl DiagnosticsExtractor {
    /// Construct a new extractor.
    ///
    /// * `marker` — issue registry marker, usually `"task:{queue}:{target}"`.
    /// * `source` — human-readable tag for the registry (e.g. `"build"`).
    pub fn new(marker: impl Into<String>, source: impl Into<String>) -> Self {
        // GNU-style: `path:line:col: severity: message`
        // Severity keywords: error, warning, note, info, hint.
        // We capture group 1=path, 2=line, 3=col, 4=severity, 5=message.
        // The path group handles Windows drive letters (C:\...) by allowing an
        // optional `X:\` prefix before the non-colon path characters.
        let gnu_3 = Regex::new(
            r"(?i)^((?:[A-Za-z]:\\[^:\n]*|[^:\n]+)):(\d+):(\d+):\s*(error|warning|note|info|hint):\s+(.+)$",
        )
        .expect("gnu_3 regex is valid");

        // GNU-style without column: `path:line: severity: message`
        let gnu_2 = Regex::new(
            r"(?i)^((?:[A-Za-z]:\\[^:\n]*|[^:\n]+)):(\d+):\s*(error|warning|note|info|hint):\s+(.+)$",
        )
        .expect("gnu_2 regex is valid");

        // TODO/FIXME/HACK/XXX — word-boundary anchored; captures the tag and the message.
        let todo_pat = Regex::new(r"(?i)\b(TODO|FIXME|HACK|XXX)\b[:\s]+(.+)$")
            .expect("todo_pat regex is valid");

        // Rustc/cargo severity header: `error[E0425]: msg` or `warning: msg`.
        // Group 1 = severity keyword, group 2 = optional `[Exxxx]` code (ignored),
        // group 3 = message.
        let rustc_header = Regex::new(
            r"(?i)^(error|warning|note|info|hint)(\[.*?\])?:\s+(.+)$",
        )
        .expect("rustc_header regex is valid");

        // Rustc/cargo source location: ` --> path:line:col`
        // Leading whitespace is required (distinguishes from other text).
        // Windows path handled same as gnu_3: optional drive prefix.
        let rustc_arrow = Regex::new(
            r"^\s+-->\s+((?:[A-Za-z]:\\[^:\n]*|[^:\n]+)):(\d+):(\d+)\s*$",
        )
        .expect("rustc_arrow regex is valid");

        Self {
            marker: marker.into(),
            source: source.into(),
            gnu_3,
            gnu_2,
            todo_pat,
            rustc_header,
            rustc_arrow,
        }
    }

    /// Extract zero or more [`NewIssue`]s from a single styled output line.
    ///
    /// The plain text content of the line is used; ANSI styling is ignored.
    /// Returns an empty `Vec` if the line matches no known pattern.
    pub fn extract_from_line(&self, line: &StyledLine) -> Vec<NewIssue> {
        self.extract_from_str(&line.text)
    }

    /// Extract zero or more [`NewIssue`]s from a plain text line.
    ///
    /// Equivalent to [`Self::extract_from_line`] but accepts a `&str` directly,
    /// useful when the caller already has plain text (e.g. from a terminal PTY
    /// output buffer where ANSI codes have already been stripped by the VT100
    /// parser).
    pub fn extract_from_str(&self, text: &str) -> Vec<NewIssue> {
        let text = text.trim_end();
        if text.is_empty() {
            return Vec::new();
        }

        // Try the three-component GNU pattern first (more specific).
        if let Some(caps) = self.gnu_3.captures(text) {
            let path = PathBuf::from(&caps[1]);
            let lineno: usize = caps[2].parse().unwrap_or(0);
            let col: usize = caps[3].parse().unwrap_or(0);
            let severity = parse_severity(&caps[4]);
            let message = caps[5].trim().to_string();

            return vec![self.make_issue(
                severity,
                message,
                Some(path),
                Some(lineno),
                Some(col),
            )];
        }

        // Fall back to the two-component GNU pattern (no column).
        if let Some(caps) = self.gnu_2.captures(text) {
            let path = PathBuf::from(&caps[1]);
            let lineno: usize = caps[2].parse().unwrap_or(0);
            let severity = parse_severity(&caps[3]);
            let message = caps[4].trim().to_string();

            return vec![self.make_issue(
                severity,
                message,
                Some(path),
                Some(lineno),
                None,
            )];
        }

        // Check for TODO/FIXME/HACK/XXX anywhere in the line.
        if let Some(caps) = self.todo_pat.captures(text) {
            let message = format!("{}: {}", &caps[1].to_uppercase(), caps[2].trim());
            return vec![self.make_issue(Severity::Todo, message, None, None, None)];
        }

        Vec::new()
    }

    // --- rustc / cargo multi-line helpers ----------------------------------

    /// Try to match a rustc/cargo severity header line such as
    /// `error[E0425]: cannot find value \`x\`` or `warning: unused variable`.
    ///
    /// Returns `(severity, message)` if matched, `None` otherwise.
    pub fn try_rustc_header(&self, text: &str) -> Option<(Severity, String)> {
        let text = text.trim_end();
        self.rustc_header.captures(text).map(|caps| {
            let severity = parse_severity(&caps[1]);
            let message = caps[3].trim().to_string();
            (severity, message)
        })
    }

    /// Try to match a rustc/cargo source-location line such as
    /// ` --> src/main.rs:5:10`.
    ///
    /// Returns `(path, line, col)` if matched, `None` otherwise.
    pub fn try_rustc_arrow(&self, text: &str) -> Option<(PathBuf, usize, usize)> {
        let text = text.trim_end();
        self.rustc_arrow.captures(text).map(|caps| {
            let path = PathBuf::from(&caps[1]);
            let line: usize = caps[2].parse().unwrap_or(0);
            let col: usize = caps[3].parse().unwrap_or(0);
            (path, line, col)
        })
    }

    // --- helpers -----------------------------------------------------------

    /// Build a [`NewIssue`] from individual components.
    ///
    /// `line` and `column` are 1-based; they are converted to 0-based
    /// [`Position`] internally.
    pub fn make_issue(
        &self,
        severity: Severity,
        message: String,
        path: Option<PathBuf>,
        line: Option<usize>,
        column: Option<usize>,
    ) -> NewIssue {
        use crate::editor::position::Position;

        let range = match (line, column) {
            (Some(l), Some(c)) => {
                let pos = Position { line: l.saturating_sub(1), column: c.saturating_sub(1) };
                Some((pos, pos))
            }
            (Some(l), None) => {
                let pos = Position { line: l.saturating_sub(1), column: 0 };
                Some((pos, pos))
            }
            _ => None,
        };

        NewIssue {
            marker: Some(self.marker.clone()),
            source: self.source.clone(),
            path,
            range,
            message,
            severity,
        }
    }
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/// Map a matched severity keyword to [`Severity`].
fn parse_severity(s: &str) -> Severity {
    match s.to_ascii_lowercase().as_str() {
        "error" => Severity::Error,
        "warning" => Severity::Warning,
        _ => Severity::Info,
    }
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;

    fn extractor() -> DiagnosticsExtractor {
        DiagnosticsExtractor::new("task:build:crate_a", "build")
    }

    fn line(text: &str) -> StyledLine {
        StyledLine { text: text.to_string(), spans: vec![] }
    }

    fn single(text: &str) -> Option<NewIssue> {
        let results = extractor().extract_from_line(&line(text));
        assert!(
            results.len() <= 1,
            "expected at most 1 result, got {}: {results:?}",
            results.len()
        );
        results.into_iter().next()
    }

    // 1. GNU 3-component: file:line:col: error: message
    #[test]
    fn gnu_error_with_column() {
        let issue = single("src/main.rs:42:10: error: type mismatch").unwrap();
        assert_eq!(issue.severity, Severity::Error);
        assert_eq!(issue.message, "type mismatch");
        assert_eq!(issue.path, Some(PathBuf::from("src/main.rs")));
        // Range: line 41 (0-indexed), col 9 (0-indexed)
        let (start, _) = issue.range.unwrap();
        assert_eq!(start.line, 41);
        assert_eq!(start.column, 9);
    }

    // 2. GNU 3-component: warning
    #[test]
    fn gnu_warning_with_column() {
        let issue = single("lib/foo.rs:10:5: warning: unused variable").unwrap();
        assert_eq!(issue.severity, Severity::Warning);
        assert_eq!(issue.message, "unused variable");
        assert_eq!(issue.path, Some(PathBuf::from("lib/foo.rs")));
    }

    // 3. GNU 2-component: file:line: error: message (no column)
    #[test]
    fn gnu_error_without_column() {
        let issue = single("build/Makefile:7: error: missing separator").unwrap();
        assert_eq!(issue.severity, Severity::Error);
        assert_eq!(issue.message, "missing separator");
        assert_eq!(issue.path, Some(PathBuf::from("build/Makefile")));
        let (start, _) = issue.range.unwrap();
        assert_eq!(start.line, 6); // 0-indexed
        assert_eq!(start.column, 0);
    }

    // 4. TODO comment → Severity::Todo
    #[test]
    fn todo_comment() {
        let issue = single("  // TODO: refactor this function").unwrap();
        assert_eq!(issue.severity, Severity::Todo);
        assert!(issue.message.contains("refactor this function"), "msg: {}", issue.message);
        assert!(issue.path.is_none());
    }

    // 5. FIXME → Severity::Todo
    #[test]
    fn fixme_comment() {
        let issue = single("FIXME: this is broken").unwrap();
        assert_eq!(issue.severity, Severity::Todo);
    }

    // 6. HACK → Severity::Todo
    #[test]
    fn hack_comment() {
        let issue = single("  HACK: workaround for upstream bug").unwrap();
        assert_eq!(issue.severity, Severity::Todo);
    }

    // 7. XXX → Severity::Todo
    #[test]
    fn xxx_comment() {
        let issue = single("XXX: needs review").unwrap();
        assert_eq!(issue.severity, Severity::Todo);
    }

    // 8. Marker and source are set correctly on all issue types.
    #[test]
    fn marker_and_source_are_set() {
        let ext = DiagnosticsExtractor::new("task:lint:mylib", "lint");
        let results = ext.extract_from_line(&line("src/lib.rs:1:1: error: oops"));
        let issue = &results[0];
        assert_eq!(issue.marker, Some("task:lint:mylib".into()));
        assert_eq!(issue.source, "lint");
    }

    // 9. Malformed / plain line → no issues.
    #[test]
    fn plain_line_produces_no_issues() {
        assert!(single("   Compiling mylib v0.1.0").is_none());
    }

    // 10. Empty line → no issues.
    #[test]
    fn empty_line_produces_no_issues() {
        assert!(single("").is_none());
    }

    // 11. Note / info keywords → Severity::Info.
    #[test]
    fn note_keyword_maps_to_info() {
        let issue = single("src/main.rs:5:3: note: consider using a semicolon").unwrap();
        assert_eq!(issue.severity, Severity::Info);
    }

    // 12. Multiple tasks produce diagnostics associated to correct marker.
    #[test]
    fn different_markers_are_independent() {
        let ext_a = DiagnosticsExtractor::new("task:build:crate_a", "build");
        let ext_b = DiagnosticsExtractor::new("task:build:crate_b", "build");
        let results_a = ext_a.extract_from_line(&line("a.rs:1:1: error: a broke"));
        let results_b = ext_b.extract_from_line(&line("b.rs:1:1: error: b broke"));
        assert_eq!(results_a[0].marker, Some("task:build:crate_a".into()));
        assert_eq!(results_b[0].marker, Some("task:build:crate_b".into()));
    }

    // 13. Case-insensitive severity matching.
    #[test]
    fn case_insensitive_severity() {
        let issue = single("src/main.rs:1:1: ERROR: uppercase error").unwrap();
        assert_eq!(issue.severity, Severity::Error);
        let issue2 = single("src/main.rs:1:1: Warning: mixed case").unwrap();
        assert_eq!(issue2.severity, Severity::Warning);
    }

    // 14. Path with spaces is not matched (safety: avoid false positives on
    //     lines like `Running: some task`).
    #[test]
    fn path_with_colon_only_no_line_number_not_matched() {
        // "Running: some task" has no digit after the first colon → no match.
        assert!(single("Running: building the project").is_none());
    }

    // 15. extract_from_str produces the same result as extract_from_line.
    #[test]
    fn extract_from_str_matches_extract_from_line() {
        let ext = extractor();
        let text = "src/main.rs:10:5: warning: dead code";
        let from_line = ext.extract_from_line(&line(text));
        let from_str = ext.extract_from_str(text);
        assert_eq!(from_line.len(), from_str.len());
        assert_eq!(from_line[0].severity, from_str[0].severity);
        assert_eq!(from_line[0].message, from_str[0].message);
    }

    // 16. extract_from_str works on ANSI-stripped terminal output.
    #[test]
    fn extract_from_str_ansi_stripped() {
        // Simulate what the terminal extractor receives after strip_ansi.
        let ext = extractor();
        // "src/main.rs:1:1: error: oh no" preceded by cleared color code.
        let text = "src/main.rs:1:1: error: oh no";
        let issue = ext.extract_from_str(text).into_iter().next().unwrap();
        assert_eq!(issue.severity, Severity::Error);
        assert_eq!(issue.message, "oh no");
    }
}