winx_code_agent/utils/
syntax.rs1use std::fmt::Write as FmtWrite;
2use std::path::Path;
3use std::process::{Command, Stdio};
4
5use tree_sitter::{Node, Parser};
6
7pub fn syntax_warning(path: &Path, content: &str) -> Option<String> {
8 let ext = path.extension().and_then(|ext| ext.to_str()).unwrap_or_default();
9 let file_name = path.file_name().and_then(|name| name.to_str()).unwrap_or_default();
10
11 match ext {
12 "json" => serde_json::from_str::<serde_json::Value>(content)
13 .err()
14 .map(|error| format!("Syntax warning: JSON parser reported: {error}")),
15 "toml" => toml::from_str::<toml::Value>(content)
16 .err()
17 .map(|error| format!("Syntax warning: TOML parser reported: {error}")),
18 "rs" => {
19 let language = tree_sitter_rust::LANGUAGE.into();
20 tree_sitter_warning(content, &language, "Rust")
21 }
22 "js" | "mjs" | "cjs" | "jsx" => {
23 let language = tree_sitter_javascript::LANGUAGE.into();
24 tree_sitter_warning(content, &language, "JavaScript")
25 }
26 "ts" => {
27 let language = tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into();
28 tree_sitter_warning(content, &language, "TypeScript")
29 }
30 "tsx" => {
31 let language = tree_sitter_typescript::LANGUAGE_TSX.into();
32 tree_sitter_warning(content, &language, "TSX")
33 }
34 "sh" | "bash" | "zsh" => {
37 let language = tree_sitter_bash::LANGUAGE.into();
38 tree_sitter_warning(content, &language, "shell")
39 }
40 "go" => {
41 let language = tree_sitter_go::LANGUAGE.into();
42 tree_sitter_warning(content, &language, "Go")
43 }
44 "c" => {
45 let language = tree_sitter_c::LANGUAGE.into();
46 tree_sitter_warning(content, &language, "C")
47 }
48 #[cfg(feature = "languages-extra")]
50 "cpp" | "cc" | "cxx" | "hpp" | "hh" | "hxx" | "h" => {
51 let language = tree_sitter_cpp::LANGUAGE.into();
52 tree_sitter_warning(content, &language, "C++")
53 }
54 "java" => {
55 let language = tree_sitter_java::LANGUAGE.into();
56 tree_sitter_warning(content, &language, "Java")
57 }
58 #[cfg(feature = "languages-extra")]
59 "rb" => {
60 let language = tree_sitter_ruby::LANGUAGE.into();
61 tree_sitter_warning(content, &language, "Ruby")
62 }
63 "css" => {
64 let language = tree_sitter_css::LANGUAGE.into();
65 tree_sitter_warning(content, &language, "CSS")
66 }
67 "html" | "htm" => {
68 let language = tree_sitter_html::LANGUAGE.into();
69 tree_sitter_warning(content, &language, "HTML")
70 }
71 #[cfg(feature = "languages-extra")]
72 "php" => {
73 let language = tree_sitter_php::LANGUAGE_PHP.into();
74 tree_sitter_warning(content, &language, "PHP")
75 }
76 #[cfg(feature = "languages-extra")]
77 "cs" => {
78 let language = tree_sitter_c_sharp::LANGUAGE.into();
79 tree_sitter_warning(content, &language, "C#")
80 }
81 #[cfg(feature = "languages-extra")]
82 "lua" => {
83 let language = tree_sitter_lua::LANGUAGE.into();
84 tree_sitter_warning(content, &language, "Lua")
85 }
86 #[cfg(feature = "languages-extra")]
87 "ex" | "exs" => {
88 let language = tree_sitter_elixir::LANGUAGE.into();
89 tree_sitter_warning(content, &language, "Elixir")
90 }
91 "py" | "pyi" => python_warning(path, content),
96 _ if matches!(file_name, "Dockerfile" | "Makefile") => None,
97 _ => None,
98 }
99}
100
101fn tree_sitter_warning(
102 content: &str,
103 language: &tree_sitter::Language,
104 language_name: &str,
105) -> Option<String> {
106 let mut parser = Parser::new();
107 if let Err(error) = parser.set_language(language) {
108 return Some(format!("Syntax warning: failed to load {language_name} parser: {error}"));
109 }
110
111 let tree = parser.parse(content, None)?;
112 let root = tree.root_node();
113 if !root.has_error() {
114 return None;
115 }
116
117 let mut message =
118 format!("Syntax warning: tree-sitter reported {language_name} syntax errors.");
119 if let Some(row) = first_error_row(root) {
120 let _ = write!(message, "\n{}", error_context(content, row));
121 }
122 Some(message)
123}
124
125fn first_error_row(node: Node<'_>) -> Option<usize> {
129 if node.is_error() || node.is_missing() {
130 return Some(node.start_position().row);
131 }
132 if !node.has_error() {
133 return None;
134 }
135 let mut cursor = node.walk();
136 for child in node.children(&mut cursor) {
137 if let Some(row) = first_error_row(child) {
138 return Some(row);
139 }
140 }
141 None
142}
143
144fn error_context(content: &str, error_row: usize) -> String {
147 let lines: Vec<&str> = content.lines().collect();
148 if lines.is_empty() {
149 return String::new();
150 }
151 let start = error_row.saturating_sub(10);
152 let end = (error_row + 11).min(lines.len());
153 let mut out = String::from("<snippet>\n");
154 for (offset, line) in lines[start..end].iter().enumerate() {
155 let line_no = start + offset + 1;
156 let marker = if start + offset == error_row { '>' } else { ' ' };
157 let _ = writeln!(out, "{marker}{line_no} {line}");
158 }
159 out.push_str("</snippet>");
160 out
161}
162
163const PY_SYNTAX_CHECK: &str = concat!(
168 "import sys\n",
169 "name = sys.argv[1] if len(sys.argv) > 1 else '<file>'\n",
174 "try:\n",
175 " compile(sys.stdin.read(), name, 'exec')\n",
176 "except SyntaxError as e:\n",
177 " print(f'{e.msg} (line {e.lineno})', file=sys.stderr)\n",
178 " sys.exit(1)\n",
179);
180
181fn python_warning(path: &Path, content: &str) -> Option<String> {
182 let python = python_interpreter()?;
183
184 let mut child = Command::new(python)
185 .args(["-c", PY_SYNTAX_CHECK])
186 .arg(path)
187 .stdin(Stdio::piped())
188 .stdout(Stdio::null())
189 .stderr(Stdio::piped())
190 .spawn()
191 .ok()?;
192
193 let mut stdin = child.stdin.take()?;
196 let owned = content.to_owned();
197 let writer = std::thread::spawn(move || {
198 use std::io::Write;
199 let _ = stdin.write_all(owned.as_bytes());
200 });
202
203 let output = child.wait_with_output().ok()?;
204 let _ = writer.join();
205
206 (!output.status.success()).then(|| {
207 let stderr = String::from_utf8_lossy(&output.stderr);
208 format!("Syntax warning: Python parser reported: {}", stderr.trim())
209 })
210}
211
212fn python_interpreter() -> Option<&'static str> {
215 static PYTHON: std::sync::OnceLock<Option<&'static str>> = std::sync::OnceLock::new();
216 *PYTHON.get_or_init(|| {
217 if Command::new("python3").arg("--version").output().is_ok() {
218 Some("python3")
219 } else if Command::new("python").arg("--version").output().is_ok() {
220 Some("python")
221 } else {
222 None
223 }
224 })
225}
226
227#[cfg(test)]
228mod tests {
229 use super::syntax_warning;
230 use std::path::Path;
231
232 #[test]
233 fn reports_invalid_json() {
234 assert!(syntax_warning(Path::new("bad.json"), "{").is_some());
235 }
236
237 #[test]
238 fn accepts_valid_rust() {
239 assert!(syntax_warning(Path::new("lib.rs"), "fn main() {}\n").is_none());
240 }
241
242 #[test]
243 fn reports_invalid_bash() {
244 assert!(syntax_warning(Path::new("script.sh"), "if true; then\n").is_some());
245 }
246
247 #[test]
248 fn reports_invalid_zsh() {
249 assert!(syntax_warning(Path::new("script.zsh"), "if true; then\n").is_some());
251 }
252
253 #[test]
254 fn error_warning_includes_snippet() {
255 let warning = syntax_warning(Path::new("lib.rs"), "fn main() {\n").unwrap_or_default();
256 assert!(warning.contains("<snippet>"), "expected snippet, got: {warning}");
257 }
258
259 #[test]
260 fn checks_go() {
261 assert!(syntax_warning(Path::new("main.go"), "package main\nfunc main() {}\n").is_none());
262 assert!(syntax_warning(Path::new("main.go"), "package main\nfunc main( {\n").is_some());
263 }
264
265 #[cfg(feature = "languages-extra")]
266 #[test]
267 fn checks_php() {
268 assert!(syntax_warning(Path::new("a.php"), "<?php echo 1; ?>\n").is_none());
269 assert!(syntax_warning(Path::new("a.php"), "<?php echo (1; ?>\n").is_some());
270 }
271
272 #[cfg(feature = "languages-extra")]
273 #[test]
274 fn checks_elixir() {
275 assert!(syntax_warning(Path::new("worker.ex"), "defmodule Worker do\nend\n").is_none());
276 assert!(syntax_warning(Path::new("worker.ex"), "defmodule Worker do\n").is_some());
277 }
278}