1use crate::errors::RecoverableParseError;
2use crate::parser::traversal::WalkDFS;
3use capitalize::Capitalize;
4use std::collections::HashSet;
5use tree_sitter::Node;
6
7pub 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
54fn 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 malformed_lines_reported.contains(&line) {
105 continue;
106 }
107 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
154fn 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
196fn 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
218fn 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 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
254pub 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 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 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 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
309fn 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
330fn 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
361fn 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 "such" if second == "that" => "a constraint statement",
379 "such" => "a valid top-level statement",
380 _ => {
381 "a valid top-level statement"
383 }
384 };
385 format!("Expected {}, but got '{}'", expected, got)
386}
387
388fn 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 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("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); 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 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}