Skip to main content

lean_ctx/core/
syntax_validate.rs

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