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 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 {
50        Some(string_start)
51    } else {
52        None
53    }
54}
55
56/// Detected error with message, hint, and source location
57struct DetectedError {
58    message: String,
59    hint: Option<String>,
60    span: Option<Range<usize>>,
61}
62
63/// Find pattern in source, returning position
64fn find_pattern(source: &str, pattern: &str) -> Option<usize> {
65    source.find(pattern)
66}
67
68/// Analyze source to detect common error patterns
69fn detect_error(source: &str) -> Option<DetectedError> {
70    // Priority 1: Unclosed string
71    if let Some(pos) = find_unclosed_string(source) {
72        return Some(DetectedError {
73            message: "unclosed string literal".into(),
74            hint: Some("add a closing '\"' to complete the string".into()),
75            span: Some(pos..pos + 1),
76        });
77    }
78
79    // Priority 2: Unclosed parentheses
80    if let Some(pos) = find_unmatched_opener(source, '(', ')') {
81        return Some(DetectedError {
82            message: "unclosed parentheses".into(),
83            hint: Some("add a closing ')' to complete the argument list".into()),
84            span: Some(pos..pos + 1),
85        });
86    }
87
88    // Priority 3: Unclosed braces
89    if let Some(pos) = find_unmatched_opener(source, '{', '}') {
90        return Some(DetectedError {
91            message: "unclosed block".into(),
92            hint: Some("add a closing '}' to complete the block".into()),
93            span: Some(pos..pos + 1),
94        });
95    }
96
97    // Priority 4: Unclosed brackets
98    if let Some(pos) = find_unmatched_opener(source, '[', ']') {
99        return Some(DetectedError {
100            message: "unclosed list".into(),
101            hint: Some("add a closing ']' to complete the list".into()),
102            span: Some(pos..pos + 1),
103        });
104    }
105
106    // Priority 5: Missing value after colon (e.g., "key: ,")
107    if let Some(pos) = find_pattern(source, ": ,") {
108        return Some(DetectedError {
109            message: "missing value after ':'".into(),
110            hint: Some("add a value after the colon".into()),
111            span: Some(pos..pos + 3),
112        });
113    }
114
115    // Priority 6: Double comma (e.g., "a, , b")
116    if let Some(pos) = find_pattern(source, ", ,") {
117        return Some(DetectedError {
118            message: "unexpected ','".into(),
119            hint: Some("remove the extra comma or add a value".into()),
120            span: Some(pos + 2..pos + 3),
121        });
122    }
123
124    // Priority 7: Comma at start of arguments (e.g., "(, a)")
125    if let Some(pos) = find_pattern(source, "(,") {
126        return Some(DetectedError {
127            message: "unexpected ',' at start of arguments".into(),
128            hint: Some("remove the leading comma".into()),
129            span: Some(pos + 1..pos + 2),
130        });
131    }
132
133    None
134}
135
136/// Format error from Chumsky's expected tokens (fallback when we can't detect the pattern)
137fn format_from_expected(found: Option<&char>, expected: &[String]) -> String {
138    let useful: Vec<_> = expected
139        .iter()
140        .filter(|s| !is_noise_pattern(s))
141        .take(3)
142        .collect();
143
144    match found {
145        Some(ch) if useful.is_empty() => format!("unexpected '{}'", ch),
146        Some(ch) => format!(
147            "unexpected '{}', expected {}",
148            ch,
149            useful
150                .iter()
151                .map(|s| s.as_str())
152                .collect::<Vec<_>>()
153                .join(" or ")
154        ),
155        None if useful.is_empty() => "unexpected end of input".into(),
156        None => format!(
157            "unexpected end of input, expected {}",
158            useful
159                .iter()
160                .map(|s| s.as_str())
161                .collect::<Vec<_>>()
162                .join(" or ")
163        ),
164    }
165}
166
167/// Pretty-print parse errors using Ariadne
168pub fn print_parse_errors(filename: &str, source: &str, errors: &[Rich<char>]) {
169    for error in errors {
170        let default_span = error.span().into_range();
171
172        // Try to detect the error pattern from source analysis
173        let (msg, hint, span) = if let Some(detected) = detect_error(source) {
174            (
175                detected.message,
176                detected.hint,
177                detected.span.unwrap_or(default_span),
178            )
179        } else {
180            // Fallback: use Chumsky's expected tokens
181            let expected: Vec<String> = error.expected().map(|e| format!("{}", e)).collect();
182            (
183                format_from_expected(error.found(), &expected),
184                None,
185                default_span,
186            )
187        };
188
189        let mut report = Report::build(ReportKind::Error, filename, span.start)
190            .with_message("Parse error")
191            .with_label(
192                Label::new((filename, span))
193                    .with_message(&msg)
194                    .with_color(Color::Red),
195            );
196
197        if let Some(hint_msg) = hint {
198            report = report.with_help(hint_msg);
199        }
200
201        report
202            .finish()
203            .print((filename, Source::from(source)))
204            .unwrap();
205    }
206}
207
208/// Format parse errors as a string without printing
209pub fn format_parse_errors(filename: &str, source: &str, errors: &[Rich<char>]) -> String {
210    let mut output = Vec::new();
211
212    for error in errors {
213        let default_span = error.span().into_range();
214
215        let (msg, hint, span) = if let Some(detected) = detect_error(source) {
216            (
217                detected.message,
218                detected.hint,
219                detected.span.unwrap_or(default_span),
220            )
221        } else {
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            .write((filename, Source::from(source)), &mut output)
245            .unwrap();
246    }
247
248    String::from_utf8(output).unwrap()
249}
250
251/// Simple error formatting without Ariadne (for tests and simple output)
252pub fn format_error_simple(error: &Rich<char>) -> String {
253    let expected: Vec<String> = error
254        .expected()
255        .map(|e| format!("{}", e))
256        .filter(|s| !is_noise_pattern(s))
257        .take(5)
258        .collect();
259
260    match error.found() {
261        Some(ch) if expected.is_empty() => {
262            format!("unexpected '{}' at position {}", ch, error.span().start)
263        }
264        Some(ch) => {
265            format!(
266                "unexpected '{}' at position {}, expected {}",
267                ch,
268                error.span().start,
269                expected.join(" or ")
270            )
271        }
272        None if expected.is_empty() => {
273            format!("unexpected end of input at position {}", error.span().start)
274        }
275        None => {
276            format!(
277                "unexpected end of input at position {}, expected {}",
278                error.span().start,
279                expected.join(" or ")
280            )
281        }
282    }
283}