Skip to main content

winx_code_agent/utils/
syntax.rs

1use 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        // zsh is checked with the bash grammar too. The previous `if ext != "zsh"`
35        // guard made the whole arm a no-op for .zsh files.
36        "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        // `.h` is parsed with the C++ grammar, which accepts C headers too.
49        #[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        // Python keeps the interpreter `compile()` check (not tree-sitter): the
92        // tree-sitter-python grammar silently accepts IndentationError and py2
93        // `print` statements, and indentation is *the* classic Python mistake —
94        // verified empirically that tree-sitter misses those, compile() catches them.
95        "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
125/// Depth-first search for the first ERROR or MISSING node, returning its 0-based
126/// start row. Mirrors wcgw's `get_context_for_errors`, which surfaces *where* the
127/// parse broke instead of just stating that it did.
128fn 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
144/// Render the ~10 lines around `error_row` (0-based) with a `>` marker on the
145/// offending line, so the model can see and fix the error in place.
146fn 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
163/// Syntax-check argv[1] via `compile()` (so no `.pyc` is emitted, unlike
164/// `py_compile`) and print just `msg (line N)` on error. Catching `SyntaxError`
165/// ourselves keeps the interpreter from dumping a traceback whose top frames are
166/// *this* wrapper — the model should see only the error in its own file.
167const PY_SYNTAX_CHECK: &str = concat!(
168    "import sys\n",
169    // The content arrives on stdin; argv[1] is only the display name for error
170    // messages. Reading stdin instead of re-opening argv[1] removes a TOCTOU /
171    // symlink-swap window — we check exactly the bytes we already validated and
172    // are about to write, not whatever is on disk at this path right now.
173    "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    // Feed the content from a separate thread so a large file can't deadlock on
194    // a full pipe buffer while the child is still being scheduled.
195    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        // `stdin` is dropped here, closing the pipe so the child sees EOF.
201    });
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
212/// The available Python interpreter, probed once. The probe used to re-spawn
213/// `python --version` on every `.py` write.
214fn 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        // Regression: the old `if ext != "zsh"` guard skipped .zsh entirely.
250        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}