lean_ctx_syntax/
syntax_validate.rs1#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14pub struct SyntaxCheck {
15 pub has_error: bool,
17 pub first_error_line: Option<usize>,
19}
20
21#[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#[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#[must_use]
88pub fn gate_edit(ext: &str, old_content: &str, new_content: &str) -> Option<String> {
89 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 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"; 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 let old = "fn main() {\n"; let new = "fn main( {\n"; 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 assert!(gate_edit("unknownext", "valid", "{[(").is_none());
161 }
162
163 #[test]
164 fn gate_allows_broken_being_fixed() {
165 let old = "fn main() {\n"; let new = "fn main() {}\n"; assert!(gate_edit("rs", old, new).is_none());
168 }
169}