Skip to main content

hypen_parser/
error.rs

1use ariadne::{Color, Label, Report, ReportKind, Source};
2use chumsky::error::Rich;
3use std::ops::Range;
4
5/// Check if an expected pattern is noise that should be filtered out
6fn is_noise_pattern(s: &str) -> bool {
7    s.contains("whitespace")
8        || s.contains("end of input")
9        || s == "something else"
10        || s.contains("'/'")
11}
12
13/// Find the position of the last unmatched opening delimiter
14fn find_unmatched_opener(source: &str, open: char, close: char) -> Option<usize> {
15    let mut stack = Vec::new();
16    let mut in_string = false;
17
18    for (i, ch) in source.char_indices() {
19        if ch == '"' {
20            in_string = !in_string;
21        } else if !in_string {
22            if ch == open {
23                stack.push(i);
24            } else if ch == close {
25                stack.pop();
26            }
27        }
28    }
29
30    stack.last().copied()
31}
32
33/// Find the position of an unclosed string literal
34fn find_unclosed_string(source: &str) -> Option<usize> {
35    let mut in_string = false;
36    let mut string_start = 0;
37
38    for (i, ch) in source.char_indices() {
39        if ch == '"' {
40            if !in_string {
41                in_string = true;
42                string_start = i;
43            } else {
44                in_string = false;
45            }
46        }
47    }
48
49    if in_string { Some(string_start) } else { None }
50}
51
52/// Detected error with message, hint, and source location
53struct DetectedError {
54    message: String,
55    hint: Option<String>,
56    span: Option<Range<usize>>,
57}
58
59/// Find pattern in source, returning position
60fn find_pattern(source: &str, pattern: &str) -> Option<usize> {
61    source.find(pattern)
62}
63
64/// Analyze source to detect common error patterns
65fn detect_error(source: &str) -> Option<DetectedError> {
66    // Priority 1: Unclosed string
67    if let Some(pos) = find_unclosed_string(source) {
68        return Some(DetectedError {
69            message: "unclosed string literal".into(),
70            hint: Some("add a closing '\"' to complete the string".into()),
71            span: Some(pos..pos + 1),
72        });
73    }
74
75    // Priority 2: Unclosed parentheses
76    if let Some(pos) = find_unmatched_opener(source, '(', ')') {
77        return Some(DetectedError {
78            message: "unclosed parentheses".into(),
79            hint: Some("add a closing ')' to complete the argument list".into()),
80            span: Some(pos..pos + 1),
81        });
82    }
83
84    // Priority 3: Unclosed braces
85    if let Some(pos) = find_unmatched_opener(source, '{', '}') {
86        return Some(DetectedError {
87            message: "unclosed block".into(),
88            hint: Some("add a closing '}' to complete the block".into()),
89            span: Some(pos..pos + 1),
90        });
91    }
92
93    // Priority 4: Unclosed brackets
94    if let Some(pos) = find_unmatched_opener(source, '[', ']') {
95        return Some(DetectedError {
96            message: "unclosed list".into(),
97            hint: Some("add a closing ']' to complete the list".into()),
98            span: Some(pos..pos + 1),
99        });
100    }
101
102    // Priority 5: Missing value after colon (e.g., "key: ,")
103    if let Some(pos) = find_pattern(source, ": ,") {
104        return Some(DetectedError {
105            message: "missing value after ':'".into(),
106            hint: Some("add a value after the colon".into()),
107            span: Some(pos..pos + 3),
108        });
109    }
110
111    // Priority 6: Double comma (e.g., "a, , b")
112    if let Some(pos) = find_pattern(source, ", ,") {
113        return Some(DetectedError {
114            message: "unexpected ','".into(),
115            hint: Some("remove the extra comma or add a value".into()),
116            span: Some(pos + 2..pos + 3),
117        });
118    }
119
120    // Priority 7: Comma at start of arguments (e.g., "(, a)")
121    if let Some(pos) = find_pattern(source, "(,") {
122        return Some(DetectedError {
123            message: "unexpected ',' at start of arguments".into(),
124            hint: Some("remove the leading comma".into()),
125            span: Some(pos + 1..pos + 2),
126        });
127    }
128
129    None
130}
131
132/// Format error from Chumsky's expected tokens (fallback when we can't detect the pattern)
133fn format_from_expected(found: Option<&char>, expected: &[String]) -> String {
134    let useful: Vec<_> = expected
135        .iter()
136        .filter(|s| !is_noise_pattern(s))
137        .take(3)
138        .collect();
139
140    match found {
141        Some(ch) if useful.is_empty() => format!("unexpected '{}'", ch),
142        Some(ch) => format!("unexpected '{}', expected {}", ch, useful.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(" or ")),
143        None if useful.is_empty() => "unexpected end of input".into(),
144        None => format!("unexpected end of input, expected {}", useful.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(" or ")),
145    }
146}
147
148/// Pretty-print parse errors using Ariadne
149pub fn print_parse_errors(filename: &str, source: &str, errors: &[Rich<char>]) {
150    for error in errors {
151        let default_span = error.span().into_range();
152
153        // Try to detect the error pattern from source analysis
154        let (msg, hint, span) = if let Some(detected) = detect_error(source) {
155            (detected.message, detected.hint, detected.span.unwrap_or(default_span))
156        } else {
157            // Fallback: use Chumsky's expected tokens
158            let expected: Vec<String> = error.expected().map(|e| format!("{}", e)).collect();
159            (format_from_expected(error.found(), &expected), None, default_span)
160        };
161
162        let mut report = Report::build(ReportKind::Error, filename, span.start)
163            .with_message("Parse error")
164            .with_label(
165                Label::new((filename, span))
166                    .with_message(&msg)
167                    .with_color(Color::Red),
168            );
169
170        if let Some(hint_msg) = hint {
171            report = report.with_help(hint_msg);
172        }
173
174        report
175            .finish()
176            .print((filename, Source::from(source)))
177            .unwrap();
178    }
179}
180
181/// Format parse errors as a string without printing
182pub fn format_parse_errors(filename: &str, source: &str, errors: &[Rich<char>]) -> String {
183    let mut output = Vec::new();
184
185    for error in errors {
186        let default_span = error.span().into_range();
187
188        let (msg, hint, span) = if let Some(detected) = detect_error(source) {
189            (detected.message, detected.hint, detected.span.unwrap_or(default_span))
190        } else {
191            let expected: Vec<String> = error.expected().map(|e| format!("{}", e)).collect();
192            (format_from_expected(error.found(), &expected), None, default_span)
193        };
194
195        let mut report = Report::build(ReportKind::Error, filename, span.start)
196            .with_message("Parse error")
197            .with_label(
198                Label::new((filename, span))
199                    .with_message(&msg)
200                    .with_color(Color::Red),
201            );
202
203        if let Some(hint_msg) = hint {
204            report = report.with_help(hint_msg);
205        }
206
207        report
208            .finish()
209            .write((filename, Source::from(source)), &mut output)
210            .unwrap();
211    }
212
213    String::from_utf8(output).unwrap()
214}
215
216/// Simple error formatting without Ariadne (for tests and simple output)
217pub fn format_error_simple(error: &Rich<char>) -> String {
218    let expected: Vec<String> = error
219        .expected()
220        .map(|e| format!("{}", e))
221        .filter(|s| !is_noise_pattern(s))
222        .take(5)
223        .collect();
224
225    match error.found() {
226        Some(ch) if expected.is_empty() => {
227            format!("unexpected '{}' at position {}", ch, error.span().start)
228        }
229        Some(ch) => {
230            format!("unexpected '{}' at position {}, expected {}", ch, error.span().start, expected.join(" or "))
231        }
232        None if expected.is_empty() => {
233            format!("unexpected end of input at position {}", error.span().start)
234        }
235        None => {
236            format!("unexpected end of input at position {}, expected {}", error.span().start, expected.join(" or "))
237        }
238    }
239}