lean_ctx/core/
syntax_validate.rs1#[derive(Clone, Copy, Debug, PartialEq, Eq)]
12pub struct SyntaxCheck {
13 pub has_error: bool,
15 pub first_error_line: Option<usize>,
17}
18
19#[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#[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#[must_use]
86pub fn gate_edit(ext: &str, old_content: &str, new_content: &str) -> Option<String> {
87 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 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"; 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 let old = "fn main() {\n"; let new = "fn main( {\n"; 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 assert!(gate_edit("unknownext", "valid", "{[(").is_none());
159 }
160
161 #[test]
162 fn gate_allows_broken_being_fixed() {
163 let old = "fn main() {\n"; let new = "fn main() {}\n"; assert!(gate_edit("rs", old, new).is_none());
166 }
167}