Skip to main content

conjure_cp_essence_parser/parser/
syntax_errors.rs

1use crate::errors::RecoverableParseError;
2use crate::parser::traversal::WalkDFS;
3use capitalize::Capitalize;
4use std::collections::HashSet;
5use tree_sitter::Node;
6
7/// Returns the absolute byte offset of the start of `row` in `source`.
8/// TODO: move to a separate module like utils, we don't want semantic tokens to depend on parser internals
9pub fn line_start_byte(source: &[u8], row: usize) -> usize {
10    let mut current_row = 0usize;
11    let mut line_start = 0usize;
12    for (idx, b) in source.iter().enumerate() {
13        if current_row == row {
14            break;
15        }
16        if *b == b'\n' {
17            current_row += 1;
18            line_start = idx + 1;
19        }
20    }
21    line_start
22}
23
24fn point_range_at(source: &str, row: usize, column: usize) -> tree_sitter::Range {
25    let line_start = line_start_byte(source.as_bytes(), row);
26    let byte = line_start + column;
27    tree_sitter::Range {
28        start_byte: byte,
29        end_byte: byte,
30        start_point: tree_sitter::Point { row, column },
31        end_point: tree_sitter::Point { row, column },
32    }
33}
34
35fn is_int_keyword_suffix(prefix: &str) -> bool {
36    let prefix = prefix.trim_end();
37    if !prefix.ends_with("int") {
38        return false;
39    }
40    let bytes = prefix.as_bytes();
41    bytes.len() == 3 || {
42        let b = bytes[bytes.len() - 4];
43        !(b.is_ascii_alphanumeric() || b == b'_')
44    }
45}
46
47fn int_domain_missing_rparen_line(line: &str, start_col: usize, end_col: usize) -> bool {
48    line.as_bytes().get(start_col) == Some(&b'(')
49        && line[end_col..].trim().is_empty()
50        && !line[start_col..].contains(')')
51        && is_int_keyword_suffix(&line[..start_col])
52}
53
54/// tree-sitter `ERROR` node spans can overlap bytes during recovery.
55/// Need to clamp to the end of the non-comment prefix so diagnostics don't include comment
56/// contents.
57fn clamp_range_before_line_comment(range: &mut tree_sitter::Range, source: &str) {
58    let Some(line) = source.lines().nth(range.start_point.row) else {
59        return;
60    };
61    let Some(dollar_idx) = line.find('$') else {
62        return;
63    };
64
65    let prefix = &line[..dollar_idx];
66    let clamped_col = prefix.trim_end().len();
67
68    if range.start_point.column > clamped_col {
69        range.start_point.column = clamped_col;
70    }
71    if range.end_point.row == range.start_point.row && range.end_point.column > clamped_col {
72        range.end_point.column = clamped_col;
73    }
74    if range.end_point.row > range.start_point.row {
75        range.end_point.row = range.start_point.row;
76        range.end_point.column = clamped_col;
77    }
78
79    let line_start = line_start_byte(source.as_bytes(), range.start_point.row);
80    range.start_byte = line_start + range.start_point.column;
81    range.end_byte = line_start + range.end_point.column;
82}
83
84pub fn detect_syntactic_errors(
85    source: &str,
86    tree: &tree_sitter::Tree,
87    errors: &mut Vec<RecoverableParseError>,
88) {
89    let mut malformed_lines_reported = HashSet::new();
90
91    let root_node = tree.root_node();
92    let retract: &dyn Fn(&tree_sitter::Node) -> bool = &|node: &tree_sitter::Node| {
93        node.is_missing() || node.is_error() || node.start_position() == node.end_position()
94    };
95
96    for node in WalkDFS::with_retract(&root_node, &retract) {
97        if node.start_position() == node.end_position() {
98            errors.push(classify_missing_token(node, source));
99            continue;
100        }
101        if node.is_error() {
102            let line = node.start_position().row;
103            // If this line has already been reported as malformed, skip all error nodes on this line
104            if malformed_lines_reported.contains(&line) {
105                continue;
106            }
107            // Ignore error nodes that start inside a single-line comment.
108            if let Some(line_str) = source.lines().nth(line)
109                && let Some(dollar_idx) = line_str.find('$')
110                && node.start_position().column >= dollar_idx
111            {
112                continue;
113            }
114
115            if let Some(missing_expression) = classify_such_that_missing_expression(&node, source) {
116                errors.push(missing_expression);
117                continue;
118            }
119
120            if is_malformed_line_error(&node, source) {
121                malformed_lines_reported.insert(line);
122                let start_byte = node.start_byte();
123                let end_byte = node.end_byte();
124
125                let last_char = source.lines().nth(line).map_or(0, |l| l.len());
126                errors.push(RecoverableParseError::new(
127                    generate_malformed_line_message(line, source),
128                    Some(tree_sitter::Range {
129                        start_byte,
130                        end_byte,
131                        start_point: tree_sitter::Point {
132                            row: line,
133                            column: 0,
134                        },
135                        end_point: tree_sitter::Point {
136                            row: line,
137                            column: last_char,
138                        },
139                    }),
140                ));
141                continue;
142            } else {
143                if let Some(missing_rparen) = classify_int_domain_missing_rparen(&node, source) {
144                    errors.push(missing_rparen);
145                    continue;
146                }
147                errors.push(classify_unexpected_token_error(node, source));
148            }
149            continue;
150        }
151    }
152}
153
154/// Tree-sitter recovery sometimes reduces `int_domain` to bare `int` and then wraps the following
155/// `(` and range text in an `ERROR` node (especially at EOF).
156/// This function detects this specific pattern and reports  "Missing )" error
157fn classify_int_domain_missing_rparen(
158    node: &tree_sitter::Node,
159    source: &str,
160) -> Option<RecoverableParseError> {
161    let start = node.start_position();
162    let end = node.end_position();
163    let line = source.lines().nth(start.row)?;
164    let comment_col = line.find('$').unwrap_or(line.len());
165    let line = &line[..comment_col];
166    let start_col = start.column.min(line.len());
167    let end_col = end.column.min(line.len());
168    if !int_domain_missing_rparen_line(line, start_col, end_col) {
169        return None;
170    }
171    let insertion_col = line.trim_end().len();
172    Some(RecoverableParseError::new(
173        "Missing )".to_string(),
174        Some(point_range_at(source, start.row, insertion_col)),
175    ))
176}
177
178fn classify_such_that_missing_expression(
179    node: &tree_sitter::Node,
180    source: &str,
181) -> Option<RecoverableParseError> {
182    let line = source.lines().nth(node.start_position().row)?;
183    if source[node.start_byte()..node.end_byte()].trim() != "such that" {
184        return None;
185    }
186    Some(RecoverableParseError::new(
187        "Missing Expression".to_string(),
188        Some(point_range_at(
189            source,
190            node.start_position().row,
191            line.trim_end().len(),
192        )),
193    ))
194}
195
196/// Classifies a missing token node and generates a diagnostic with a context-aware message.
197fn classify_missing_token(node: Node, source: &str) -> RecoverableParseError {
198    let mut range = tree_sitter::Range {
199        start_byte: node.start_byte(),
200        end_byte: node.end_byte(),
201        start_point: node.start_position(),
202        end_point: node.end_position(),
203    };
204    clamp_range_before_line_comment(&mut range, source);
205
206    let message = if let Some(parent) = node.parent() {
207        match parent.kind() {
208            "letting_variable_declaration" => "Missing Expression or Domain".to_string(),
209            _ => format!("Missing {}", user_friendly_token_name(node.kind(), false)),
210        }
211    } else {
212        format!("Missing {}", user_friendly_token_name(node.kind(), false))
213    };
214
215    RecoverableParseError::new(message, Some(range))
216}
217
218/// Classifies an unexpected token error node and generates a diagnostic.
219fn classify_unexpected_token_error(node: Node, source_code: &str) -> RecoverableParseError {
220    let mut range = tree_sitter::Range {
221        start_byte: node.start_byte().min(source_code.len()),
222        end_byte: node.end_byte().min(source_code.len()),
223        start_point: node.start_position(),
224        end_point: node.end_position(),
225    };
226    clamp_range_before_line_comment(&mut range, source_code);
227
228    let message = if let Some(parent) = node.parent() {
229        // Extract the unexpected token text, handling out-of-range indices safely.
230        // NOTE: tree-sitter byte offsets can land inside UTF-8 codepoints; decoding lossily avoids panics.
231        let src_token: std::borrow::Cow<'_, str> = source_code
232            .as_bytes()
233            .get(range.start_byte..range.end_byte)
234            .map(String::from_utf8_lossy)
235            .unwrap_or_else(|| std::borrow::Cow::Borrowed("<unknown>"));
236        let src_token = src_token.trim_end();
237
238        if parent.kind() == "program" {
239            format!("Unexpected {}", src_token)
240        } else {
241            format!(
242                "Unexpected {} inside {}",
243                src_token,
244                user_friendly_token_name(parent.kind(), true)
245            )
246        }
247    } else {
248        "Unexpected token".to_string()
249    };
250
251    RecoverableParseError::new(message, Some(range))
252}
253
254/// Determines if an error node represents a malformed line error.
255pub fn is_malformed_line_error(node: &tree_sitter::Node, source: &str) -> bool {
256    let parent = node.parent();
257    let grandparent = parent.and_then(|n| n.parent());
258    let root = grandparent.and_then(|n| n.parent());
259
260    if let (Some(parent), Some(grandparent), Some(root)) = (parent, grandparent, root)
261        && parent.kind() == "set_comparison"
262        && grandparent.kind() == "comparison_expr"
263        && root.kind() == "program"
264    {
265        return true;
266    }
267
268    // check parent kinds to see if the error is a constraint continuation
269    let mut curr = node.parent();
270    while let Some(n) = curr {
271        let kind = n.kind();
272        if matches!(
273            kind,
274            "find_statement"
275                | "given_statement"
276                | "letting_statement"
277                | "dominance_relation"
278                | "bool_expr"
279                | "comparison_expr"
280                | "arithmetic_expr"
281                | "atom"
282        ) {
283            return false;
284        }
285        curr = n.parent();
286    }
287
288    // check for the first non-whitespace character on the line before the error node
289    let line = source.lines().nth(node.start_position().row).unwrap_or("");
290    let first_non_witespace = line
291        .as_bytes()
292        .iter()
293        .take_while(|b| b.is_ascii_whitespace())
294        .count();
295
296    // if the error node is before or at the first non-whitespace character, it's a malformed line error
297    // if the first non-whitespace character is after the error node, it could be a constraint continuation
298    if node.start_position().column <= first_non_witespace || error_node_out_of_range(node, source)
299    {
300        if first_non_witespace > 0 && is_constraint_continuation(source, node.start_position().row)
301        {
302            return false;
303        }
304        return true;
305    }
306    false
307}
308
309/// Checks if a line is a continuation of a constraint (i.e., it ends with a comma or has "such that" at the start).
310fn is_constraint_continuation(source: &str, row: usize) -> bool {
311    let lines: Vec<&str> = source.lines().collect();
312    if row == 0 {
313        return false;
314    }
315
316    let mut r = row;
317    while r > 0 {
318        r -= 1;
319        let line = lines.get(r).copied().unwrap_or("");
320        let line = line.split('$').next().unwrap_or("").trim_end();
321        if line.trim().is_empty() {
322            continue;
323        }
324        let lower = line.trim_start().to_ascii_lowercase();
325        return lower.starts_with("such that") || line.ends_with(',');
326    }
327    false
328}
329
330/// Coverts a token name into a more user-friendly format for error messages.
331/// Removes underscores, replaces certain keywords with more natural language, and adds appropriate articles.
332fn user_friendly_token_name(token: &str, article: bool) -> String {
333    let capitalized = if token.contains("atom") {
334        "Expression".to_string()
335    } else if token == "COLON" {
336        ":".to_string()
337    } else {
338        let friendly_name = token
339            .replace("literal", "")
340            .replace("int", "Integer")
341            .replace("expr", "Expression")
342            .replace('_', " ");
343        friendly_name
344            .split_whitespace()
345            .map(|word| word.capitalize())
346            .collect::<Vec<_>>()
347            .join(" ")
348    };
349
350    if !article {
351        return capitalized;
352    }
353    let first_char = capitalized.chars().next().unwrap();
354    let article = match first_char.to_ascii_lowercase() {
355        'a' | 'e' | 'i' | 'o' | 'u' => "an",
356        _ => "a",
357    };
358    format!("{} {}", article, capitalized)
359}
360
361// Generates an informative error message for malformed lines
362fn generate_malformed_line_message(line: usize, source: &str) -> String {
363    let got = source.lines().nth(line).unwrap_or("").trim();
364    let got = got.split('$').next().unwrap_or("").trim_end();
365    let got = got.replace('"', "\\\"");
366    let mut words = got.split_whitespace();
367    let first = words.next().unwrap_or("").to_ascii_lowercase();
368    let second = words.next().unwrap_or("").to_ascii_lowercase();
369
370    let expected = match first.as_str() {
371        "find" => "a find declaration statement",
372        "findAux" => "a findAux declaration statement",
373        "letting" => "a letting declaration statement",
374        "given" => "a given declaration statement",
375        "where" => "an instantiation condition",
376        "minimising" | "maximising" => "an objective statement",
377        // Check for invalid constraint statement
378        "such" if second == "that" => "a constraint statement",
379        "such" => "a valid top-level statement",
380        _ => {
381            // Default case for unrecognized starting tokens
382            "a valid top-level statement"
383        }
384    };
385    format!("Expected {}, but got '{}'", expected, got)
386}
387
388/// Returns true if the node's start or end column is out of range for its line in the source.
389fn error_node_out_of_range(node: &tree_sitter::Node, source: &str) -> bool {
390    let lines: Vec<&str> = source.lines().collect();
391    let start = node.start_position();
392    let end = node.end_position();
393
394    let start_line_len = lines.get(start.row).map_or(0, |l| l.len());
395    let end_line_len = lines.get(end.row).map_or(0, |l| l.len());
396
397    (start.column > start_line_len) || (end.column > end_line_len)
398}
399
400#[cfg(test)]
401mod test {
402
403    use super::{
404        clamp_range_before_line_comment, detect_syntactic_errors, int_domain_missing_rparen_line,
405        is_int_keyword_suffix, is_malformed_line_error, line_start_byte, point_range_at,
406        user_friendly_token_name,
407    };
408    use crate::errors::RecoverableParseError;
409    use crate::{parser::traversal::WalkDFS, util::get_tree};
410
411    /// Helper function for tests to compare the actual error with the expected one.
412    fn assert_essence_parse_error_eq(a: &RecoverableParseError, b: &RecoverableParseError) {
413        assert_eq!(a.msg, b.msg, "error messages differ");
414        assert_eq!(a.range, b.range, "error ranges differ");
415    }
416
417    #[test]
418    fn malformed_line() {
419        let source = " a,a,b: int(1..3)";
420        let (tree, _) = get_tree(source).expect("Should parse");
421        let root_node = tree.root_node();
422
423        let error_node = WalkDFS::with_retract(&root_node, &|_node| false)
424            .find(|node| node.is_error())
425            .expect("Should find an error node");
426
427        assert!(is_malformed_line_error(&error_node, source));
428    }
429
430    #[test]
431    fn malformed_find_message() {
432        let source = "find >=lex,b,c: int(1..3)";
433        let message = super::generate_malformed_line_message(0, source);
434        assert_eq!(
435            message,
436            "Expected a find declaration statement, but got 'find >=lex,b,c: int(1..3)'"
437        );
438    }
439
440    #[test]
441    fn malformed_top_level_message() {
442        let source = "a >=lex,b,c: int(1..3)";
443        let message = super::generate_malformed_line_message(0, source);
444        assert_eq!(
445            message,
446            "Expected a valid top-level statement, but got 'a >=lex,b,c: int(1..3)'"
447        );
448    }
449
450    #[test]
451    fn malformed_objective_message() {
452        let source = "maximising %x";
453        let message = super::generate_malformed_line_message(0, source);
454        assert_eq!(
455            message,
456            "Expected an objective statement, but got 'maximising %x'"
457        );
458    }
459
460    #[test]
461    fn malformed_letting_message() {
462        let source = "letting % A be 3";
463        let message = super::generate_malformed_line_message(0, source);
464        assert_eq!(
465            message,
466            "Expected a letting declaration statement, but got 'letting % A be 3'"
467        );
468    }
469
470    #[test]
471    fn malformed_constraint_message() {
472        let source = "such that % A > 3";
473        let message = super::generate_malformed_line_message(0, source);
474        assert_eq!(
475            message,
476            "Expected a constraint statement, but got 'such that % A > 3'"
477        );
478    }
479
480    #[test]
481    fn malformed_top_level_message_2() {
482        let source = "such % A > 3";
483        let message = super::generate_malformed_line_message(0, source);
484        assert_eq!(
485            message,
486            "Expected a valid top-level statement, but got 'such % A > 3'"
487        );
488    }
489
490    #[test]
491    fn malformed_given_message() {
492        let source = "given 1..3)";
493        let message = super::generate_malformed_line_message(0, source);
494        assert_eq!(
495            message,
496            "Expected a given declaration statement, but got 'given 1..3)'"
497        );
498    }
499
500    #[test]
501    fn malformed_where_message() {
502        let source = "where x>6";
503        let message = super::generate_malformed_line_message(0, source);
504        assert_eq!(
505            message,
506            "Expected an instantiation condition, but got 'where x>6'"
507        );
508    }
509
510    #[test]
511    fn user_friendly_token_name_article() {
512        assert_eq!(
513            user_friendly_token_name("int_domain", false),
514            "Integer Domain"
515        );
516        assert_eq!(
517            user_friendly_token_name("int_domain", true),
518            "an Integer Domain"
519        );
520        // assert_eq!(user_friendly_token_name("atom", true), "an Expression");
521        assert_eq!(user_friendly_token_name("COLON", false), ":");
522    }
523
524    #[test]
525    fn missing_domain() {
526        let source = "find x:";
527        let (tree, _) = get_tree(source).expect("Should parse");
528        let mut errors = vec![];
529        detect_syntactic_errors(source, &tree, &mut errors);
530        assert_eq!(errors.len(), 1, "Expected exactly one diagnostic");
531
532        let error = &errors[0];
533
534        assert_essence_parse_error_eq(
535            error,
536            &RecoverableParseError::new(
537                "Missing Domain".to_string(),
538                Some(tree_sitter::Range {
539                    start_byte: 7,
540                    end_byte: 7,
541                    start_point: tree_sitter::Point { row: 0, column: 7 },
542                    end_point: tree_sitter::Point { row: 0, column: 7 },
543                }),
544            ),
545        );
546    }
547
548    #[test]
549    fn line_start_byte_returns_correct_offsets() {
550        let source = "a\nbc\ndef";
551        let bytes = source.as_bytes();
552        assert_eq!(line_start_byte(bytes, 0), 0);
553        assert_eq!(line_start_byte(bytes, 1), 2);
554        assert_eq!(line_start_byte(bytes, 2), 5);
555    }
556
557    #[test]
558    fn point_range_at_returns_correct_zero_length_range() {
559        let source = "a\nbc\ndef";
560        let range = point_range_at(source, 1, 1); // points to 'c'
561        assert_eq!(range.start_point.row, 1);
562        assert_eq!(range.start_point.column, 1);
563        assert_eq!(range.end_point, range.start_point);
564        assert_eq!(range.start_byte, 3);
565        assert_eq!(range.end_byte, 3);
566    }
567
568    #[test]
569    fn clamp_range_before_line_comment_clamps_end_to_before_dollar() {
570        let source = "find x: int(1..3 $comment";
571        let mut range = tree_sitter::Range {
572            start_byte: 0,
573            end_byte: source.len(),
574            start_point: tree_sitter::Point { row: 0, column: 0 },
575            end_point: tree_sitter::Point {
576                row: 0,
577                column: source.len(),
578            },
579        };
580
581        clamp_range_before_line_comment(&mut range, source);
582
583        // "find x: int(1..3" ends at byte/column 16; the `$comment` suffix must be excluded.
584        assert_eq!(range.end_point.row, 0);
585        assert_eq!(range.end_point.column, 16);
586        assert_eq!(range.end_byte, 16);
587    }
588
589    #[test]
590    fn int_keyword_suffix_checks_word_boundary() {
591        assert!(is_int_keyword_suffix("find x: int"));
592        assert!(!is_int_keyword_suffix("foo"));
593        assert!(!is_int_keyword_suffix("mint"));
594    }
595
596    #[test]
597    fn int_domain_missing_rparen_line_positive_and_negative_cases() {
598        let ok = "find x: int(1..2";
599        let start = ok.find('(').unwrap();
600        assert!(int_domain_missing_rparen_line(ok, start, ok.len()));
601
602        let has_rparen = "find x: int(1..2)";
603        let start = has_rparen.find('(').unwrap();
604        assert!(!int_domain_missing_rparen_line(
605            has_rparen,
606            start,
607            has_rparen.len()
608        ));
609
610        let trailing = "find x: int(1..2 foo";
611        let start = trailing.find('(').unwrap();
612        let end = trailing.find(" foo").unwrap();
613        assert!(!int_domain_missing_rparen_line(trailing, start, end));
614
615        let print_like = "find x: print(1..2";
616        let start = print_like.find('(').unwrap();
617        assert!(!int_domain_missing_rparen_line(
618            print_like,
619            start,
620            print_like.len()
621        ));
622    }
623}