Skip to main content

harn_parser/
diagnostic.rs

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