Skip to main content

lean_ctx_syntax/
syntax_validate.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Post-edit syntax gate (#1008): a tree-sitter parse check used to reject an
4//! edit that turns a *cleanly parsing* file into a broken one.
5//!
6//! Principle (plan Säule 3): only the *clean → broken* transition is a real
7//! regression. We never reject when the pre-edit file already had parse errors
8//! (the model may be fixing them), and we skip entirely for languages without a
9//! grammar — so the gate is a safety net, never an obstacle. The decision logic
10//! lives in [`gate_edit`]; [`check_syntax`] is the raw parse probe.
11
12/// Outcome of a tree-sitter parse probe.
13#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14pub struct SyntaxCheck {
15    /// `true` when the parse tree contains an ERROR or MISSING node.
16    pub has_error: bool,
17    /// 1-based line of the first error/missing node, when one was located.
18    pub first_error_line: Option<usize>,
19}
20
21/// Parse `content` as `ext` and report whether the tree has syntax errors.
22///
23/// Returns `None` when the language is unsupported or tree-sitter is compiled
24/// out — the caller then skips the gate rather than guessing.
25#[cfg(feature = "tree-sitter")]
26pub fn check_syntax(content: &str, ext: &str) -> Option<SyntaxCheck> {
27    use std::cell::RefCell;
28    use tree_sitter::Parser;
29
30    let language = crate::deep_queries::get_language(ext)?;
31
32    thread_local! {
33        static PARSER: RefCell<Parser> = RefCell::new(Parser::new());
34    }
35
36    let tree = PARSER.with(|p| {
37        let mut parser = p.borrow_mut();
38        parser.set_language(&language).ok()?;
39        parser.parse(content.as_bytes(), None)
40    })?;
41
42    let root = tree.root_node();
43    if !root.has_error() {
44        return Some(SyntaxCheck {
45            has_error: false,
46            first_error_line: None,
47        });
48    }
49    Some(SyntaxCheck {
50        has_error: true,
51        first_error_line: first_error_line(root),
52    })
53}
54
55#[cfg(not(feature = "tree-sitter"))]
56pub fn check_syntax(_content: &str, _ext: &str) -> Option<SyntaxCheck> {
57    None
58}
59
60/// Depth-first search for the first ERROR/MISSING node, returning its 1-based
61/// start line. Only descends into subtrees that actually contain an error, so it
62/// is effectively O(error-path), not O(tree).
63#[cfg(feature = "tree-sitter")]
64fn first_error_line(node: tree_sitter::Node) -> Option<usize> {
65    if node.is_error() || node.is_missing() {
66        return Some(node.start_position().row + 1);
67    }
68    let mut cursor = node.walk();
69    for child in node.children(&mut cursor) {
70        if child.is_error() || child.is_missing() {
71            return Some(child.start_position().row + 1);
72        }
73        if child.has_error()
74            && let Some(line) = first_error_line(child)
75        {
76            return Some(line);
77        }
78    }
79    None
80}
81
82/// The post-edit gate decision (#1008). Returns `Some(reason)` only for the
83/// clean → broken regression — the single case worth blocking — and `None`
84/// (allow the write) for every other situation:
85/// unsupported language, tree-sitter off, an already-broken pre-edit file, or a
86/// post-edit file that still parses.
87#[must_use]
88pub fn gate_edit(ext: &str, old_content: &str, new_content: &str) -> Option<String> {
89    // Pre-edit must parse cleanly for a regression to be meaningful.
90    let pre = check_syntax(old_content, ext)?;
91    if pre.has_error {
92        return None;
93    }
94    let post = check_syntax(new_content, ext)?;
95    if !post.has_error {
96        return None;
97    }
98    let loc = post
99        .first_error_line
100        .map_or_else(String::new, |l| format!(" near line {l}"));
101    Some(format!(
102        "ERROR: edit rejected — it introduces a syntax error{loc} (.{ext}). \
103         The current file on disk parses cleanly, but the proposed edit \
104         would break it. No write was made. Fix the snippet and retry, \
105         or pass validate_syntax=false to override."
106    ))
107}
108
109#[cfg(all(test, feature = "tree-sitter"))]
110mod tests {
111    use super::*;
112
113    #[test]
114    fn clean_code_has_no_error() {
115        let c = check_syntax("fn main() {}\n", "rs").unwrap();
116        assert!(!c.has_error);
117        assert_eq!(c.first_error_line, None);
118    }
119
120    #[test]
121    fn broken_code_reports_error_with_line() {
122        // Missing closing brace → parse error.
123        let c = check_syntax("fn main() {\n    let x =\n", "rs").unwrap();
124        assert!(c.has_error);
125        assert!(c.first_error_line.is_some());
126    }
127
128    #[test]
129    fn unsupported_extension_is_none() {
130        assert!(check_syntax("anything at all", "unknownext").is_none());
131    }
132
133    #[test]
134    fn gate_blocks_clean_to_broken() {
135        let old = "fn main() {}\n";
136        let new = "fn main() {\n"; // unbalanced brace
137        let reason = gate_edit("rs", old, new).expect("clean→broken must be gated");
138        assert!(reason.contains("syntax error"));
139        assert!(reason.contains("validate_syntax=false"));
140    }
141
142    #[test]
143    fn gate_allows_broken_to_broken() {
144        // Pre-edit already broken → never our regression to block (model may fix).
145        let old = "fn main() {\n"; // broken
146        let new = "fn main( {\n"; // still broken
147        assert!(gate_edit("rs", old, new).is_none());
148    }
149
150    #[test]
151    fn gate_allows_clean_to_clean() {
152        let old = "fn main() {}\n";
153        let new = "fn main() { let x = 1; }\n";
154        assert!(gate_edit("rs", old, new).is_none());
155    }
156
157    #[test]
158    fn gate_skips_unsupported_language() {
159        // No grammar → no opinion, always allow.
160        assert!(gate_edit("unknownext", "valid", "{[(").is_none());
161    }
162
163    #[test]
164    fn gate_allows_broken_being_fixed() {
165        let old = "fn main() {\n"; // broken
166        let new = "fn main() {}\n"; // fixed
167        assert!(gate_edit("rs", old, new).is_none());
168    }
169}