Skip to main content

winx_code_agent/utils/
syntax.rs

1use std::fmt::Write as FmtWrite;
2use std::path::Path;
3use std::process::Command;
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        "cpp" | "cc" | "cxx" | "hpp" | "hh" | "hxx" | "h" => {
50            let language = tree_sitter_cpp::LANGUAGE.into();
51            tree_sitter_warning(content, &language, "C++")
52        }
53        "java" => {
54            let language = tree_sitter_java::LANGUAGE.into();
55            tree_sitter_warning(content, &language, "Java")
56        }
57        "rb" => {
58            let language = tree_sitter_ruby::LANGUAGE.into();
59            tree_sitter_warning(content, &language, "Ruby")
60        }
61        "css" => {
62            let language = tree_sitter_css::LANGUAGE.into();
63            tree_sitter_warning(content, &language, "CSS")
64        }
65        "html" | "htm" => {
66            let language = tree_sitter_html::LANGUAGE.into();
67            tree_sitter_warning(content, &language, "HTML")
68        }
69        "php" => {
70            let language = tree_sitter_php::LANGUAGE_PHP.into();
71            tree_sitter_warning(content, &language, "PHP")
72        }
73        "cs" => {
74            let language = tree_sitter_c_sharp::LANGUAGE.into();
75            tree_sitter_warning(content, &language, "C#")
76        }
77        "lua" => {
78            let language = tree_sitter_lua::LANGUAGE.into();
79            tree_sitter_warning(content, &language, "Lua")
80        }
81        "py" | "pyi" => python_warning(path),
82        _ if matches!(file_name, "Dockerfile" | "Makefile") => None,
83        _ => None,
84    }
85}
86
87fn tree_sitter_warning(
88    content: &str,
89    language: &tree_sitter::Language,
90    language_name: &str,
91) -> Option<String> {
92    let mut parser = Parser::new();
93    if let Err(error) = parser.set_language(language) {
94        return Some(format!("Syntax warning: failed to load {language_name} parser: {error}"));
95    }
96
97    let tree = parser.parse(content, None)?;
98    let root = tree.root_node();
99    if !root.has_error() {
100        return None;
101    }
102
103    let mut message =
104        format!("Syntax warning: tree-sitter reported {language_name} syntax errors.");
105    if let Some(row) = first_error_row(root) {
106        let _ = write!(message, "\n{}", error_context(content, row));
107    }
108    Some(message)
109}
110
111/// Depth-first search for the first ERROR or MISSING node, returning its 0-based
112/// start row. Mirrors wcgw's `get_context_for_errors`, which surfaces *where* the
113/// parse broke instead of just stating that it did.
114fn first_error_row(node: Node<'_>) -> Option<usize> {
115    if node.is_error() || node.is_missing() {
116        return Some(node.start_position().row);
117    }
118    if !node.has_error() {
119        return None;
120    }
121    let mut cursor = node.walk();
122    for child in node.children(&mut cursor) {
123        if let Some(row) = first_error_row(child) {
124            return Some(row);
125        }
126    }
127    None
128}
129
130/// Render the ~10 lines around `error_row` (0-based) with a `>` marker on the
131/// offending line, so the model can see and fix the error in place.
132fn error_context(content: &str, error_row: usize) -> String {
133    let lines: Vec<&str> = content.lines().collect();
134    if lines.is_empty() {
135        return String::new();
136    }
137    let start = error_row.saturating_sub(10);
138    let end = (error_row + 11).min(lines.len());
139    let mut out = String::from("<snippet>\n");
140    for (offset, line) in lines[start..end].iter().enumerate() {
141        let line_no = start + offset + 1;
142        let marker = if start + offset == error_row { '>' } else { ' ' };
143        let _ = writeln!(out, "{marker}{line_no} {line}");
144    }
145    out.push_str("</snippet>");
146    out
147}
148
149fn python_warning(path: &Path) -> Option<String> {
150    let python = if Command::new("python3").arg("--version").output().is_ok() {
151        "python3"
152    } else if Command::new("python").arg("--version").output().is_ok() {
153        "python"
154    } else {
155        return None;
156    };
157
158    let output = Command::new(python).args(["-m", "py_compile"]).arg(path).output().ok()?;
159    (!output.status.success()).then(|| {
160        let stderr = String::from_utf8_lossy(&output.stderr);
161        format!("Syntax warning: Python parser reported:\n{}", stderr.trim())
162    })
163}
164
165#[cfg(test)]
166mod tests {
167    use super::syntax_warning;
168    use std::path::Path;
169
170    #[test]
171    fn reports_invalid_json() {
172        assert!(syntax_warning(Path::new("bad.json"), "{").is_some());
173    }
174
175    #[test]
176    fn accepts_valid_rust() {
177        assert!(syntax_warning(Path::new("lib.rs"), "fn main() {}\n").is_none());
178    }
179
180    #[test]
181    fn reports_invalid_bash() {
182        assert!(syntax_warning(Path::new("script.sh"), "if true; then\n").is_some());
183    }
184
185    #[test]
186    fn reports_invalid_zsh() {
187        // Regression: the old `if ext != "zsh"` guard skipped .zsh entirely.
188        assert!(syntax_warning(Path::new("script.zsh"), "if true; then\n").is_some());
189    }
190
191    #[test]
192    fn error_warning_includes_snippet() {
193        let warning = syntax_warning(Path::new("lib.rs"), "fn main() {\n").unwrap_or_default();
194        assert!(warning.contains("<snippet>"), "expected snippet, got: {warning}");
195    }
196
197    #[test]
198    fn checks_go() {
199        assert!(syntax_warning(Path::new("main.go"), "package main\nfunc main() {}\n").is_none());
200        assert!(syntax_warning(Path::new("main.go"), "package main\nfunc main( {\n").is_some());
201    }
202
203    #[test]
204    fn checks_php() {
205        assert!(syntax_warning(Path::new("a.php"), "<?php echo 1; ?>\n").is_none());
206        assert!(syntax_warning(Path::new("a.php"), "<?php echo (1; ?>\n").is_some());
207    }
208}