diff --git a/src/cfengine_cli/lint.py b/src/cfengine_cli/lint.py index d6acb3a..38f326d 100644 --- a/src/cfengine_cli/lint.py +++ b/src/cfengine_cli/lint.py @@ -493,14 +493,26 @@ def navigate(self, node) -> None: return +def _last_real_sibling_before(node: Node) -> Node | None: + """Walk backwards from node through its preceding siblings, skipping + comments and macros, to find the last real (content) sibling.""" + sib = node.prev_sibling + while sib is not None and sib.type in ("comment", "macro"): + sib = sib.prev_sibling + return sib + + def _check_syntax(policy_file: PolicyFile, state: State) -> int: - """Iterate over a syntax tree and print errors if it is empty or has syntax - errors. + """Iterate over a syntax tree and print errors if it is empty, has syntax + errors, or is missing expected tokens (e.g. a dropped ';'). Notably, printing syntax errors _does not happen during parsing_. Tree sitter has already fully parsed the policy, we iterate over the result - and see if it has inserted any "ERROR" nodes. + and see if it has inserted any "ERROR" nodes, or any "MISSING" nodes + (tree-sitter's error recovery can silently insert a synthetic token, + e.g. a missing ';' right before a closing '}', without ever producing + an ERROR node. Stops at first error. Returns number of errors, always 0 or 1 in this case. """ @@ -522,15 +534,26 @@ def _check_syntax(policy_file: PolicyFile, state: State) -> int: state.start_file(policy_file) for node in policy_file.nodes: state.navigate(node) - if node.type != "ERROR": - continue line = node.range.start_point[0] + 1 column = node.range.start_point[1] + 1 - _highlight_range(node, lines) - location = state.get_location_extended(line, column) - print(f"Error: Syntax error {location}") - state.end_file() - return 1 + if node.type == "ERROR": + _highlight_range(node, lines) + location = state.get_location_extended(line, column) + print(f"Error: Syntax error {location}") + state.end_file() + return 1 + if node.is_missing: + # To avoid pointing at #comment/@macro -lines + prev = _last_real_sibling_before(node) + report_node = prev if prev is not None else node + _highlight_range(report_node, lines) + line = report_node.range.end_point[0] + 1 + column = report_node.range.end_point[1] + 1 + missing = node.type if not node.is_named else f"token: {node.type}" + location = state.get_location_extended(line, column) + print(f"Error: Missing '{missing}' {location}") + state.end_file() + return 1 state.end_file() return 0 diff --git a/tests/lint/020_missing_braces.expected.txt b/tests/lint/020_missing_braces.expected.txt new file mode 100644 index 0000000..962ede2 --- /dev/null +++ b/tests/lint/020_missing_braces.expected.txt @@ -0,0 +1,7 @@ + + "handlers" slist => { "my_bundle_name" }; + "args" slist => { "foo" ; + ^---^ +Error: Missing '}' at tests/lint/020_missing_braces.x.cf:11:28 +FAIL: tests/lint/020_missing_braces.x.cf (1 error) +Failure, 1 error in total. diff --git a/tests/lint/020_missing_braces.x.cf b/tests/lint/020_missing_braces.x.cf new file mode 100644 index 0000000..d1bcfe7 --- /dev/null +++ b/tests/lint/020_missing_braces.x.cf @@ -0,0 +1,15 @@ +bundle agent my_bundle_name(arg) +{ + reports: + "$(arg)"; +} + +bundle agent main +{ + vars: + "handlers" slist => { "my_bundle_name" }; + "args" slist => { "foo" ; + + methods: + "" usebundle => $(handlers)(@(args)); +} diff --git a/tests/lint/020_missing_semicolon.expected.txt b/tests/lint/020_missing_semicolon.expected.txt new file mode 100644 index 0000000..87e4741 --- /dev/null +++ b/tests/lint/020_missing_semicolon.expected.txt @@ -0,0 +1,7 @@ + + "" + usebundle => $(handlers)(@(args)) + ^-------------------------------^ +Error: Missing ';' at tests/lint/020_missing_semicolon.x.cf:15:40 +FAIL: tests/lint/020_missing_semicolon.x.cf (1 error) +Failure, 1 error in total. diff --git a/tests/lint/020_missing_semicolon.x.cf b/tests/lint/020_missing_semicolon.x.cf new file mode 100644 index 0000000..633f1df --- /dev/null +++ b/tests/lint/020_missing_semicolon.x.cf @@ -0,0 +1,18 @@ +bundle agent my_bundle_name(arg) +{ + reports: + "$(arg)"; +} + +bundle agent main +{ + vars: + "handlers" slist => { "my_bundle_name" }; + "args" slist => { "foo" }; + + methods: + "" + usebundle => $(handlers)(@(args)) + # Comments and such stuff point ^ not this line in error + +}