Skip to main content

hypen_parser/
error.rs

1use ariadne::{Color, Label, Report, ReportKind, Source};
2pub use 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 chars: Vec<char> = source.chars().collect();
17    let mut i = 0;
18    let mut in_double_string = false;
19    let mut in_single_string = false;
20
21    // Build a byte offset map for char_indices parity
22    let byte_offsets: Vec<usize> = source.char_indices().map(|(pos, _)| pos).collect();
23
24    while i < chars.len() {
25        let ch = chars[i];
26        let in_string = in_double_string || in_single_string;
27
28        if in_string && ch == '\\' {
29            // Skip escaped character
30            i += 2;
31            continue;
32        }
33
34        if ch == '"' && !in_single_string {
35            in_double_string = !in_double_string;
36        } else if ch == '\'' && !in_double_string {
37            in_single_string = !in_single_string;
38        } else if !in_string {
39            if ch == open {
40                stack.push(byte_offsets[i]);
41            } else if ch == close {
42                stack.pop();
43            }
44        }
45
46        i += 1;
47    }
48
49    stack.last().copied()
50}
51
52/// Find the position of an unclosed string literal
53fn find_unclosed_string(source: &str) -> Option<usize> {
54    let chars: Vec<char> = source.chars().collect();
55    let byte_offsets: Vec<usize> = source.char_indices().map(|(pos, _)| pos).collect();
56    let mut i = 0;
57    let mut in_double_string = false;
58    let mut in_single_string = false;
59    let mut string_start = 0;
60
61    while i < chars.len() {
62        let ch = chars[i];
63        let in_string = in_double_string || in_single_string;
64
65        if in_string && ch == '\\' {
66            // Skip escaped character
67            i += 2;
68            continue;
69        }
70
71        if ch == '"' && !in_single_string {
72            if !in_double_string {
73                in_double_string = true;
74                string_start = byte_offsets[i];
75            } else {
76                in_double_string = false;
77            }
78        } else if ch == '\'' && !in_double_string {
79            if !in_single_string {
80                in_single_string = true;
81                string_start = byte_offsets[i];
82            } else {
83                in_single_string = false;
84            }
85        }
86
87        i += 1;
88    }
89
90    if in_double_string || in_single_string {
91        Some(string_start)
92    } else {
93        None
94    }
95}
96
97/// Detected error with message, hint, and source location
98struct DetectedError {
99    message: String,
100    hint: Option<String>,
101    span: Option<Range<usize>>,
102}
103
104/// Find pattern in source, returning position
105fn find_pattern(source: &str, pattern: &str) -> Option<usize> {
106    source.find(pattern)
107}
108
109/// Analyze source to detect common error patterns
110fn detect_error(source: &str) -> Option<DetectedError> {
111    // Priority 1: Unclosed string
112    if let Some(pos) = find_unclosed_string(source) {
113        return Some(DetectedError {
114            message: "unclosed string literal".into(),
115            hint: Some("add a closing '\"' to complete the string".into()),
116            span: Some(pos..pos + 1),
117        });
118    }
119
120    // Priority 2: Unclosed parentheses
121    if let Some(pos) = find_unmatched_opener(source, '(', ')') {
122        return Some(DetectedError {
123            message: "unclosed parentheses".into(),
124            hint: Some("add a closing ')' to complete the argument list".into()),
125            span: Some(pos..pos + 1),
126        });
127    }
128
129    // Priority 3: Unclosed braces
130    if let Some(pos) = find_unmatched_opener(source, '{', '}') {
131        return Some(DetectedError {
132            message: "unclosed block".into(),
133            hint: Some("add a closing '}' to complete the block".into()),
134            span: Some(pos..pos + 1),
135        });
136    }
137
138    // Priority 4: Unclosed brackets
139    if let Some(pos) = find_unmatched_opener(source, '[', ']') {
140        return Some(DetectedError {
141            message: "unclosed list".into(),
142            hint: Some("add a closing ']' to complete the list".into()),
143            span: Some(pos..pos + 1),
144        });
145    }
146
147    // Priority 5: Missing value after colon (e.g., "key: ,")
148    if let Some(pos) = find_pattern(source, ": ,") {
149        return Some(DetectedError {
150            message: "missing value after ':'".into(),
151            hint: Some("add a value after the colon".into()),
152            span: Some(pos..pos + 3),
153        });
154    }
155
156    // Priority 6: Double comma (e.g., "a, , b")
157    if let Some(pos) = find_pattern(source, ", ,") {
158        return Some(DetectedError {
159            message: "unexpected ','".into(),
160            hint: Some("remove the extra comma or add a value".into()),
161            span: Some(pos + 2..pos + 3),
162        });
163    }
164
165    // Priority 7: Comma at start of arguments (e.g., "(, a)")
166    if let Some(pos) = find_pattern(source, "(,") {
167        return Some(DetectedError {
168            message: "unexpected ',' at start of arguments".into(),
169            hint: Some("remove the leading comma".into()),
170            span: Some(pos + 1..pos + 2),
171        });
172    }
173
174    None
175}
176
177/// Format error from Chumsky's expected tokens (fallback when we can't detect the pattern)
178fn format_from_expected(found: Option<&char>, expected: &[String]) -> String {
179    let useful: Vec<_> = expected
180        .iter()
181        .filter(|s| !is_noise_pattern(s))
182        .take(3)
183        .collect();
184
185    match found {
186        Some(ch) if useful.is_empty() => format!("unexpected '{}'", ch),
187        Some(ch) => format!(
188            "unexpected '{}', expected {}",
189            ch,
190            useful
191                .iter()
192                .map(|s| s.as_str())
193                .collect::<Vec<_>>()
194                .join(" or ")
195        ),
196        None if useful.is_empty() => "unexpected end of input".into(),
197        None => format!(
198            "unexpected end of input, expected {}",
199            useful
200                .iter()
201                .map(|s| s.as_str())
202                .collect::<Vec<_>>()
203                .join(" or ")
204        ),
205    }
206}
207
208/// Pretty-print parse errors using Ariadne
209pub fn print_parse_errors(filename: &str, source: &str, errors: &[Rich<char>]) {
210    for error in errors {
211        let default_span = error.span().into_range();
212
213        // Try to detect the error pattern from source analysis
214        let (msg, hint, span) = if let Some(detected) = detect_error(source) {
215            (
216                detected.message,
217                detected.hint,
218                detected.span.unwrap_or(default_span),
219            )
220        } else {
221            // Fallback: use Chumsky's expected tokens
222            let expected: Vec<String> = error.expected().map(|e| format!("{}", e)).collect();
223            (
224                format_from_expected(error.found(), &expected),
225                None,
226                default_span,
227            )
228        };
229
230        let mut report = Report::build(ReportKind::Error, filename, span.start)
231            .with_message("Parse error")
232            .with_label(
233                Label::new((filename, span))
234                    .with_message(&msg)
235                    .with_color(Color::Red),
236            );
237
238        if let Some(hint_msg) = hint {
239            report = report.with_help(hint_msg);
240        }
241
242        report
243            .finish()
244            .print((filename, Source::from(source)))
245            .unwrap();
246    }
247}
248
249/// Format parse errors as a string without printing
250pub fn format_parse_errors(filename: &str, source: &str, errors: &[Rich<char>]) -> String {
251    let mut output = Vec::new();
252
253    for error in errors {
254        let default_span = error.span().into_range();
255
256        let (msg, hint, span) = if let Some(detected) = detect_error(source) {
257            (
258                detected.message,
259                detected.hint,
260                detected.span.unwrap_or(default_span),
261            )
262        } else {
263            let expected: Vec<String> = error.expected().map(|e| format!("{}", e)).collect();
264            (
265                format_from_expected(error.found(), &expected),
266                None,
267                default_span,
268            )
269        };
270
271        let mut report = Report::build(ReportKind::Error, filename, span.start)
272            .with_message("Parse error")
273            .with_label(
274                Label::new((filename, span))
275                    .with_message(&msg)
276                    .with_color(Color::Red),
277            );
278
279        if let Some(hint_msg) = hint {
280            report = report.with_help(hint_msg);
281        }
282
283        report
284            .finish()
285            .write((filename, Source::from(source)), &mut output)
286            .unwrap();
287    }
288
289    String::from_utf8(output).unwrap()
290}
291
292/// Simple error formatting without Ariadne (for tests and simple output)
293pub fn format_error_simple(error: &Rich<char>) -> String {
294    let expected: Vec<String> = error
295        .expected()
296        .map(|e| format!("{}", e))
297        .filter(|s| !is_noise_pattern(s))
298        .take(5)
299        .collect();
300
301    match error.found() {
302        Some(ch) if expected.is_empty() => {
303            format!("unexpected '{}' at position {}", ch, error.span().start)
304        }
305        Some(ch) => {
306            format!(
307                "unexpected '{}' at position {}, expected {}",
308                ch,
309                error.span().start,
310                expected.join(" or ")
311            )
312        }
313        None if expected.is_empty() => {
314            format!("unexpected end of input at position {}", error.span().start)
315        }
316        None => {
317            format!(
318                "unexpected end of input at position {}, expected {}",
319                error.span().start,
320                expected.join(" or ")
321            )
322        }
323    }
324}