Skip to main content

harn_parser/
diagnostic.rs

1use harn_lexer::Span;
2
3/// Compute the Levenshtein edit distance between two strings.
4pub fn edit_distance(a: &str, b: &str) -> usize {
5    let a_chars: Vec<char> = a.chars().collect();
6    let b_chars: Vec<char> = b.chars().collect();
7    let n = b_chars.len();
8    let mut prev = (0..=n).collect::<Vec<_>>();
9    let mut curr = vec![0; n + 1];
10    for (i, ac) in a_chars.iter().enumerate() {
11        curr[0] = i + 1;
12        for (j, bc) in b_chars.iter().enumerate() {
13            let cost = if ac == bc { 0 } else { 1 };
14            curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(prev[j] + cost);
15        }
16        std::mem::swap(&mut prev, &mut curr);
17    }
18    prev[n]
19}
20
21/// Find the closest match to `name` among `candidates`, within `max_dist` edits.
22pub fn find_closest_match<'a>(
23    name: &str,
24    candidates: impl Iterator<Item = &'a str>,
25    max_dist: usize,
26) -> Option<&'a str> {
27    candidates
28        .filter(|c| c.len().abs_diff(name.len()) <= max_dist)
29        .min_by_key(|c| edit_distance(name, c))
30        .filter(|c| edit_distance(name, c) <= max_dist && *c != name)
31}
32
33/// Render a Rust-style diagnostic message.
34///
35/// Example output:
36/// ```text
37/// error: undefined variable `x`
38///   --> example.harn:5:12
39///    |
40///  5 |     let y = x + 1
41///    |             ^ not found in this scope
42/// ```
43pub fn render_diagnostic(
44    source: &str,
45    filename: &str,
46    span: &Span,
47    severity: &str,
48    message: &str,
49    label: Option<&str>,
50    help: Option<&str>,
51) -> String {
52    let mut out = String::new();
53
54    // Header: severity + message
55    out.push_str(severity);
56    out.push_str(": ");
57    out.push_str(message);
58    out.push('\n');
59
60    // Location line
61    let line_num = span.line;
62    let col_num = span.column;
63
64    let gutter_width = line_num.to_string().len();
65
66    out.push_str(&format!(
67        "{:>width$}--> {filename}:{line_num}:{col_num}\n",
68        " ",
69        width = gutter_width + 1,
70    ));
71
72    // Blank gutter
73    out.push_str(&format!("{:>width$} |\n", " ", width = gutter_width + 1));
74
75    // Source line
76    let source_line_opt = source.lines().nth(line_num.wrapping_sub(1));
77    if let Some(source_line) = source_line_opt.filter(|_| line_num > 0) {
78        out.push_str(&format!(
79            "{:>width$} | {source_line}\n",
80            line_num,
81            width = gutter_width + 1,
82        ));
83
84        // Caret line
85        if let Some(label_text) = label {
86            // Calculate span display width using character counts, not byte offsets
87            let span_len = if span.end > span.start && span.start <= source.len() {
88                let span_text = &source[span.start.min(source.len())..span.end.min(source.len())];
89                span_text.chars().count().max(1)
90            } else {
91                1
92            };
93            let col_num = col_num.max(1); // ensure at least 1
94            let padding = " ".repeat(col_num - 1);
95            let carets = "^".repeat(span_len);
96            out.push_str(&format!(
97                "{:>width$} | {padding}{carets} {label_text}\n",
98                " ",
99                width = gutter_width + 1,
100            ));
101        }
102    }
103
104    // Help line
105    if let Some(help_text) = help {
106        out.push_str(&format!(
107            "{:>width$} = help: {help_text}\n",
108            " ",
109            width = gutter_width + 1,
110        ));
111    }
112
113    out
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    #[test]
121    fn test_basic_diagnostic() {
122        let source = "pipeline default(task) {\n    let y = x + 1\n}";
123        let span = Span {
124            start: 28,
125            end: 29,
126            line: 2,
127            column: 13,
128            end_line: 2,
129        };
130        let output = render_diagnostic(
131            source,
132            "example.harn",
133            &span,
134            "error",
135            "undefined variable `x`",
136            Some("not found in this scope"),
137            None,
138        );
139        assert!(output.contains("error: undefined variable `x`"));
140        assert!(output.contains("--> example.harn:2:13"));
141        assert!(output.contains("let y = x + 1"));
142        assert!(output.contains("^ not found in this scope"));
143    }
144
145    #[test]
146    fn test_diagnostic_with_help() {
147        let source = "let y = xx + 1";
148        let span = Span {
149            start: 8,
150            end: 10,
151            line: 1,
152            column: 9,
153            end_line: 1,
154        };
155        let output = render_diagnostic(
156            source,
157            "test.harn",
158            &span,
159            "error",
160            "undefined variable `xx`",
161            Some("not found in this scope"),
162            Some("did you mean `x`?"),
163        );
164        assert!(output.contains("help: did you mean `x`?"));
165    }
166
167    #[test]
168    fn test_multiline_source() {
169        let source = "line1\nline2\nline3";
170        let span = Span::with_offsets(6, 11, 2, 1); // "line2"
171        let result = render_diagnostic(
172            source,
173            "test.harn",
174            &span,
175            "error",
176            "bad line",
177            Some("here"),
178            None,
179        );
180        assert!(result.contains("line2"));
181        assert!(result.contains("^^^^^"));
182    }
183
184    #[test]
185    fn test_single_char_span() {
186        let source = "let x = 42";
187        let span = Span::with_offsets(4, 5, 1, 5); // "x"
188        let result = render_diagnostic(
189            source,
190            "test.harn",
191            &span,
192            "warning",
193            "unused",
194            Some("never used"),
195            None,
196        );
197        assert!(result.contains("^"));
198        assert!(result.contains("never used"));
199    }
200
201    #[test]
202    fn test_with_help() {
203        let source = "let y = reponse";
204        let span = Span::with_offsets(8, 15, 1, 9);
205        let result = render_diagnostic(
206            source,
207            "test.harn",
208            &span,
209            "error",
210            "undefined",
211            None,
212            Some("did you mean `response`?"),
213        );
214        assert!(result.contains("help:"));
215        assert!(result.contains("response"));
216    }
217}