rez_lsp_server/validation/
python_validator.rs1use super::{Severity, ValidationIssue, Validator};
4use crate::core::Result;
5use regex::Regex;
6
7pub struct PythonValidator {
9 patterns: PythonPatterns,
11}
12
13struct PythonPatterns {
14 #[allow(dead_code)]
16 invalid_indent: Regex,
17 #[allow(dead_code)]
19 unclosed_brackets: Regex,
20 #[allow(dead_code)]
22 invalid_strings: Regex,
23 invalid_names: Regex,
25 missing_colons: Regex,
27}
28
29impl PythonValidator {
30 pub fn new() -> Result<Self> {
32 let patterns = PythonPatterns {
33 invalid_indent: Regex::new(r"^[ \t]+[^ \t#]")?,
34 unclosed_brackets: Regex::new(r"[\[\(\{][^\]\)\}]*$")?,
35 invalid_strings: Regex::new(r#"(["'])[^"']*$"#)?,
36 invalid_names: Regex::new(r"\b\d+[a-zA-Z_]")?,
37 missing_colons: Regex::new(
38 r"^\s*(if|elif|else|for|while|def|class|try|except|finally|with)\b[^:]*$",
39 )?,
40 };
41
42 Ok(Self { patterns })
43 }
44
45 fn check_indentation(&self, content: &str) -> Vec<ValidationIssue> {
47 let mut issues = Vec::new();
48
49 for (line_num, line) in content.lines().enumerate() {
50 let line_num = line_num as u32 + 1;
51
52 if line.trim().is_empty() || line.trim().starts_with('#') {
54 continue;
55 }
56
57 let indent = line.len() - line.trim_start().len();
58
59 if line.starts_with('\t') && line.contains(' ') {
61 issues.push(
62 ValidationIssue::new(
63 Severity::Error,
64 line_num,
65 1,
66 indent as u32,
67 "Mixed tabs and spaces in indentation",
68 "E101",
69 )
70 .with_suggestion("Use either tabs or spaces consistently"),
71 );
72 }
73
74 if line_num > 1 {
76 let lines: Vec<&str> = content.lines().collect();
77 if let Some(prev_line) = lines.get((line_num - 2) as usize) {
78 if prev_line.trim().ends_with(':') && indent == 0 && !line.trim().is_empty() {
79 issues.push(
80 ValidationIssue::new(
81 Severity::Error,
82 line_num,
83 1,
84 1,
85 "Expected an indented block",
86 "E111",
87 )
88 .with_suggestion("Indent this line"),
89 );
90 }
91 }
92 }
93 }
94
95 issues
96 }
97
98 fn check_syntax_errors(&self, content: &str) -> Vec<ValidationIssue> {
100 let mut issues = Vec::new();
101
102 for (line_num, line) in content.lines().enumerate() {
103 let line_num = line_num as u32 + 1;
104 let trimmed = line.trim();
105
106 if trimmed.is_empty() || trimmed.starts_with('#') {
108 continue;
109 }
110
111 if self.patterns.missing_colons.is_match(trimmed) {
113 let col = line.len() as u32;
114 issues.push(
115 ValidationIssue::new(
116 Severity::Error,
117 line_num,
118 col,
119 1,
120 "Missing colon at end of statement",
121 "E999",
122 )
123 .with_suggestion("Add ':' at the end of the line"),
124 );
125 }
126
127 if self.check_unclosed_strings(line) {
129 issues.push(
130 ValidationIssue::new(
131 Severity::Error,
132 line_num,
133 1,
134 line.len() as u32,
135 "Unclosed string literal",
136 "E902",
137 )
138 .with_suggestion("Close the string literal"),
139 );
140 }
141
142 if let Some(mat) = self.patterns.invalid_names.find(trimmed) {
144 issues.push(
145 ValidationIssue::new(
146 Severity::Error,
147 line_num,
148 mat.start() as u32 + 1,
149 mat.len() as u32,
150 "Invalid variable name (cannot start with digit)",
151 "E999",
152 )
153 .with_suggestion("Variable names must start with a letter or underscore"),
154 );
155 }
156 }
157
158 issues
159 }
160
161 fn check_unclosed_strings(&self, line: &str) -> bool {
163 let mut in_single_quote = false;
164 let mut in_double_quote = false;
165 let mut escaped = false;
166
167 for ch in line.chars() {
168 if escaped {
169 escaped = false;
170 continue;
171 }
172
173 match ch {
174 '\\' => escaped = true,
175 '\'' if !in_double_quote => in_single_quote = !in_single_quote,
176 '"' if !in_single_quote => in_double_quote = !in_double_quote,
177 _ => {}
178 }
179 }
180
181 in_single_quote || in_double_quote
182 }
183
184 fn check_bracket_matching(&self, content: &str) -> Vec<ValidationIssue> {
186 let mut issues = Vec::new();
187 let mut bracket_stack = Vec::new();
188 let mut _line_positions: Vec<u32> = Vec::new();
189
190 let mut current_line = 1u32;
192 let mut current_col = 1u32;
193
194 for ch in content.chars() {
195 match ch {
196 '(' | '[' | '{' => {
197 bracket_stack.push((ch, current_line, current_col));
198 }
199 ')' | ']' | '}' => {
200 if let Some((open_bracket, _open_line, _open_col)) = bracket_stack.pop() {
201 let expected_close = match open_bracket {
202 '(' => ')',
203 '[' => ']',
204 '{' => '}',
205 _ => unreachable!(),
206 };
207
208 if ch != expected_close {
209 issues.push(
210 ValidationIssue::new(
211 Severity::Error,
212 current_line,
213 current_col,
214 1,
215 format!(
216 "Mismatched bracket: expected '{}', found '{}'",
217 expected_close, ch
218 ),
219 "E999",
220 )
221 .with_suggestion(format!(
222 "Change '{}' to '{}'",
223 ch, expected_close
224 )),
225 );
226 }
227 } else {
228 issues.push(
229 ValidationIssue::new(
230 Severity::Error,
231 current_line,
232 current_col,
233 1,
234 format!("Unmatched closing bracket '{}'", ch),
235 "E999",
236 )
237 .with_suggestion(
238 "Remove the extra closing bracket or add matching opening bracket",
239 ),
240 );
241 }
242 }
243 '\n' => {
244 current_line += 1;
245 current_col = 1;
246 continue;
247 }
248 _ => {}
249 }
250
251 current_col += 1;
252 }
253
254 for (bracket, line, col) in bracket_stack {
256 let expected_close = match bracket {
257 '(' => ')',
258 '[' => ']',
259 '{' => '}',
260 _ => unreachable!(),
261 };
262
263 issues.push(
264 ValidationIssue::new(
265 Severity::Error,
266 line,
267 col,
268 1,
269 format!("Unclosed bracket '{}'", bracket),
270 "E999",
271 )
272 .with_suggestion(format!("Add closing bracket '{}'", expected_close)),
273 );
274 }
275
276 issues
277 }
278
279 fn check_style_issues(&self, content: &str) -> Vec<ValidationIssue> {
281 let mut issues = Vec::new();
282
283 for (line_num, line) in content.lines().enumerate() {
284 let line_num = line_num as u32 + 1;
285
286 if line.len() > 79 {
288 issues.push(
289 ValidationIssue::new(
290 Severity::Warning,
291 line_num,
292 80,
293 (line.len() - 79) as u32,
294 "Line too long (>79 characters)",
295 "W501",
296 )
297 .with_suggestion("Break long lines or use line continuation"),
298 );
299 }
300
301 if line.ends_with(' ') || line.ends_with('\t') {
303 let trimmed_len = line.trim_end().len();
304 issues.push(
305 ValidationIssue::new(
306 Severity::Warning,
307 line_num,
308 trimmed_len as u32 + 1,
309 (line.len() - trimmed_len) as u32,
310 "Trailing whitespace",
311 "W291",
312 )
313 .with_suggestion("Remove trailing whitespace"),
314 );
315 }
316 }
317
318 issues
319 }
320}
321
322impl Default for PythonValidator {
323 fn default() -> Self {
324 Self::new().expect("Failed to create PythonValidator")
325 }
326}
327
328impl Validator for PythonValidator {
329 fn validate(&self, content: &str, _file_path: &str) -> Result<Vec<ValidationIssue>> {
330 let mut issues = Vec::new();
331
332 issues.extend(self.check_indentation(content));
334 issues.extend(self.check_syntax_errors(content));
335 issues.extend(self.check_bracket_matching(content));
336 issues.extend(self.check_style_issues(content));
337
338 issues.sort_by(|a, b| a.line.cmp(&b.line).then_with(|| a.column.cmp(&b.column)));
340
341 Ok(issues)
342 }
343
344 fn name(&self) -> &str {
345 "PythonValidator"
346 }
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352
353 #[test]
354 fn test_python_validator_creation() {
355 let validator = PythonValidator::new();
356 assert!(validator.is_ok());
357 }
358
359 #[test]
360 fn test_valid_python_code() {
361 let validator = PythonValidator::new().unwrap();
362 let content = r#"
363name = "test"
364version = "1.0.0"
365description = "A test package"
366
367def build():
368 pass
369"#;
370
371 let issues = validator.validate(content, "test.py").unwrap();
372 assert!(issues.iter().all(|i| i.severity != Severity::Error));
374 }
375
376 #[test]
377 fn test_syntax_errors() {
378 let validator = PythonValidator::new().unwrap();
379 let content = r#"
380name = "test
381version = 1.0.0"
382def build(
383 pass
384"#;
385
386 let issues = validator.validate(content, "test.py").unwrap();
387 assert!(issues.iter().any(|i| i.severity == Severity::Error));
388 }
389
390 #[test]
391 fn test_indentation_errors() {
392 let validator = PythonValidator::new().unwrap();
393 let content = r#"def build():
394pass"#;
395
396 let issues = validator.validate(content, "test.py").unwrap();
397 for issue in &issues {
399 println!("Issue: {} - {}", issue.code, issue.message);
400 }
401 assert!(issues.iter().any(|i| i.code.starts_with("E1")));
402 }
403
404 #[test]
405 fn test_bracket_matching() {
406 let validator = PythonValidator::new().unwrap();
407 let content = r#"requires = ["python", "maya"
408tools = {"tool1": "path1"
409"#;
410
411 let issues = validator.validate(content, "test.py").unwrap();
412 for issue in &issues {
414 println!("Issue: {} - {}", issue.code, issue.message);
415 }
416 assert!(issues
418 .iter()
419 .any(|i| i.message.contains("bracket") || i.message.contains("Unclosed")));
420 }
421}