| |
| |
| |
| |
|
|
| use std::path::Path; |
|
|
| use tree_sitter::Node; |
|
|
| use crate::parser::dsl::ParsedSource; |
|
|
| use super::{Diagnostic, DiagnosticSeverity, SourceSpan}; |
|
|
| |
| pub fn collect_syntax_errors(parsed: &ParsedSource) -> Vec<Diagnostic> { |
| let root = parsed.tree.root_node(); |
| let mut diagnostics = Vec::new(); |
| collect_errors_recursive(&root, &parsed.source, &parsed.path, &mut diagnostics); |
| diagnostics |
| } |
|
|
| fn collect_errors_recursive( |
| node: &Node, |
| source: &str, |
| file: &Path, |
| diagnostics: &mut Vec<Diagnostic>, |
| ) { |
| if node.is_error() { |
| diagnostics.push(make_error_diagnostic(node, source, file)); |
| return; |
| } |
|
|
| if node.is_missing() { |
| diagnostics.push(make_missing_diagnostic(node, file)); |
| return; |
| } |
|
|
| let mut cursor = node.walk(); |
| for child in node.children(&mut cursor) { |
| if child.has_error() || child.is_error() || child.is_missing() { |
| collect_errors_recursive(&child, source, file, diagnostics); |
| } |
| } |
| } |
|
|
| |
| fn make_error_diagnostic(node: &Node, source: &str, file: &Path) -> Diagnostic { |
| let span = clamped_span(node, source, file); |
|
|
| |
| |
| if is_swallowed_field(node) { |
| let diag_span = node |
| .parent() |
| .and_then(|p| find_child_of_kind(&p, "=")) |
| .map(|eq| SourceSpan::from_node(&eq, file)) |
| .unwrap_or(span); |
| return Diagnostic { |
| message: "Expected a value".to_string(), |
| severity: DiagnosticSeverity::Error, |
| span: diag_span, |
| }; |
| } |
|
|
| Diagnostic { |
| message: "Syntax error".to_string(), |
| severity: DiagnosticSeverity::Error, |
| span, |
| } |
| } |
|
|
| |
| fn make_missing_diagnostic(node: &Node, file: &Path) -> Diagnostic { |
| let message = match node.kind() { |
| |
| |
| "true" | "false" | "boolean" => "Expected a value".to_string(), |
|
|
| |
| "}" => "Missing closing `}`".to_string(), |
| "]" => "Missing closing `]`".to_string(), |
| "\"" => "Unclosed string literal".to_string(), |
|
|
| |
| "identifier" => missing_identifier_message(node), |
|
|
| kind => format!("Expected {kind}"), |
| }; |
|
|
| Diagnostic { |
| message, |
| severity: DiagnosticSeverity::Error, |
| span: SourceSpan::from_node(node, file), |
| } |
| } |
|
|
| |
| fn missing_identifier_message(node: &Node) -> String { |
| if let Some(parent) = node.parent() { |
| match parent.kind() { |
| "entity_id" => return "Missing entity ID".to_string(), |
| "entity_type" => return "Missing entity type".to_string(), |
| "schema_name" => return "Missing schema name".to_string(), |
| "field_name" => return "Missing field name".to_string(), |
| _ => {} |
| } |
| } |
| "Missing identifier".to_string() |
| } |
|
|
| |
| |
| fn clamped_span(node: &Node, source: &str, file: &Path) -> SourceSpan { |
| let start = node.start_position(); |
| let end = node.end_position(); |
|
|
| if end.row > start.row { |
| |
| let line_end = source[node.start_byte()..] |
| .find('\n') |
| .map(|i| node.start_byte() + i) |
| .unwrap_or(source.len()); |
| let end_col = line_end - (node.start_byte() - start.column); |
| SourceSpan { |
| file: file.to_path_buf(), |
| start_line: start.row as u32, |
| start_col: start.column as u32, |
| end_line: start.row as u32, |
| end_col: end_col as u32, |
| } |
| } else { |
| SourceSpan::from_node(node, file) |
| } |
| } |
|
|
| |
| |
| |
| |
| fn is_swallowed_field(node: &Node) -> bool { |
| let parent = match node.parent() { |
| Some(p) if p.kind() == "field" => p, |
| _ => return false, |
| }; |
| |
| if find_child_of_kind(&parent, "=").is_none() { |
| return false; |
| } |
| |
| |
| has_child_kind(node, "identifier") && has_child_kind(node, "=") |
| } |
|
|
| fn find_child_of_kind<'a>(node: &Node<'a>, kind: &str) -> Option<Node<'a>> { |
| let mut cursor = node.walk(); |
| node.children(&mut cursor).find(|c| c.kind() == kind) |
| } |
|
|
| fn has_child_kind(node: &Node, kind: &str) -> bool { |
| find_child_of_kind(node, kind).is_some() |
| } |
|
|
| #[cfg(test)] |
| mod tests { |
| use crate::parser::dsl::parse_source; |
|
|
| use super::*; |
| use std::path::PathBuf; |
|
|
| fn diagnostics_for(source: &str) -> Vec<Diagnostic> { |
| let parsed = |
| parse_source(String::from(source), Some(PathBuf::from("test.firm"))).unwrap(); |
| collect_syntax_errors(&parsed) |
| } |
|
|
| #[test] |
| fn test_no_errors_for_valid_source() { |
| let diagnostics = diagnostics_for( |
| r#" |
| contact john_doe { |
| name = "John Doe" |
| age = 42 |
| } |
| "#, |
| ); |
| assert!(diagnostics.is_empty()); |
| } |
|
|
| #[test] |
| fn test_unclosed_brace_at_eof() { |
| let diagnostics = diagnostics_for("contact john {\n name = \"John\""); |
| assert_eq!(diagnostics.len(), 1); |
| assert_eq!(diagnostics[0].message, "Missing closing `}`"); |
| } |
|
|
| #[test] |
| fn test_unclosed_brace_with_following_entity() { |
| let diagnostics = |
| diagnostics_for("contact john {\n name = \"John\"\n\ncontact jane {\n name = \"Jane\"\n}"); |
| |
| assert!(diagnostics.iter().any(|d| d.message.contains("}"))); |
| } |
|
|
| #[test] |
| fn test_unclosed_string() { |
| let diagnostics = |
| diagnostics_for("contact john {\n name = \"John\n age = 42\n}"); |
| assert!(!diagnostics.is_empty()); |
| |
| assert_eq!(diagnostics[0].span.start_line, diagnostics[0].span.end_line); |
| } |
|
|
| #[test] |
| fn test_missing_value_at_end() { |
| let diagnostics = diagnostics_for("contact john {\n name =\n}"); |
| assert_eq!(diagnostics.len(), 1); |
| assert_eq!(diagnostics[0].message, "Expected a value"); |
| } |
|
|
| #[test] |
| fn test_missing_value_with_following_field() { |
| |
| let diagnostics = diagnostics_for("contact john {\n name =\n age = 42\n}"); |
| assert_eq!(diagnostics.len(), 1); |
| assert_eq!(diagnostics[0].message, "Expected a value"); |
| } |
|
|
| #[test] |
| fn test_missing_value_mid_block() { |
| let diagnostics = diagnostics_for( |
| "contact john {\n title = \"CTO\"\n name =\n age = 42\n}", |
| ); |
| assert_eq!(diagnostics.len(), 1); |
| assert_eq!(diagnostics[0].message, "Expected a value"); |
| } |
|
|
| #[test] |
| fn test_missing_entity_id() { |
| let diagnostics = diagnostics_for("contact {\n name = \"Test\"\n}"); |
| assert_eq!(diagnostics.len(), 1); |
| assert_eq!(diagnostics[0].message, "Missing entity ID"); |
| } |
|
|
| #[test] |
| fn test_missing_field_name() { |
| let diagnostics = diagnostics_for("contact john {\n = \"Test\"\n}"); |
| assert_eq!(diagnostics.len(), 1); |
| assert_eq!(diagnostics[0].message, "Syntax error"); |
| } |
|
|
| #[test] |
| fn test_error_includes_file_path() { |
| let diagnostics = diagnostics_for("contact {\n name = \"Test\"\n}"); |
| assert_eq!(diagnostics[0].span.file, PathBuf::from("test.firm")); |
| } |
|
|
| #[test] |
| fn test_unclosed_string_mid_entity() { |
| let diagnostics = diagnostics_for( |
| "contact john {\n title = \"CTO\"\n name = \"John\n age = 42\n}", |
| ); |
| assert!(!diagnostics.is_empty()); |
| |
| assert_eq!(diagnostics[0].span.start_line, diagnostics[0].span.end_line); |
| } |
|
|
| #[test] |
| fn test_unclosed_string_first_field() { |
| let diagnostics = diagnostics_for("contact john {\n name = \"John\n}"); |
| assert!(!diagnostics.is_empty()); |
| assert_eq!(diagnostics[0].span.start_line, diagnostics[0].span.end_line); |
| } |
|
|
| #[test] |
| fn test_error_spans_full_line() { |
| |
| let diagnostics = |
| diagnostics_for("contact john {\n name = \"John\n age = 42\n}"); |
| assert!(!diagnostics.is_empty()); |
| let span = &diagnostics[0].span; |
| assert_eq!(span.start_line, span.end_line); |
| |
| assert!(span.end_col > span.start_col + 1); |
| } |
| } |
|
|