Skip to main content

rez_lsp_server/validation/
python_validator.rs

1//! Python syntax validation for package.py files.
2
3use super::{Severity, ValidationIssue, Validator};
4use crate::core::Result;
5use regex::Regex;
6
7/// Validates Python syntax in package.py files.
8pub struct PythonValidator {
9    /// Regex patterns for common Python syntax issues
10    patterns: PythonPatterns,
11}
12
13struct PythonPatterns {
14    /// Pattern for detecting invalid indentation
15    #[allow(dead_code)]
16    invalid_indent: Regex,
17    /// Pattern for detecting unclosed brackets
18    #[allow(dead_code)]
19    unclosed_brackets: Regex,
20    /// Pattern for detecting invalid string literals
21    #[allow(dead_code)]
22    invalid_strings: Regex,
23    /// Pattern for detecting invalid variable names
24    invalid_names: Regex,
25    /// Pattern for detecting missing colons
26    missing_colons: Regex,
27}
28
29impl PythonValidator {
30    /// Create a new Python validator.
31    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    /// Check for indentation issues.
46    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            // Skip empty lines and comments
53            if line.trim().is_empty() || line.trim().starts_with('#') {
54                continue;
55            }
56
57            let indent = line.len() - line.trim_start().len();
58
59            // Check for mixed tabs and spaces
60            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            // Simple indentation check: if previous line ended with ':', this line should be indented
75            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    /// Check for syntax errors.
99    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            // Skip empty lines and comments
107            if trimmed.is_empty() || trimmed.starts_with('#') {
108                continue;
109            }
110
111            // Check for missing colons
112            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            // Check for unclosed strings
128            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            // Check for invalid variable names
143            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    /// Check for unclosed string literals.
162    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    /// Check for bracket matching.
185    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        // Track line positions for error reporting
191        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        // Check for unclosed brackets
255        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    /// Check for common Python style issues.
280    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            // Check line length (PEP 8 recommends 79 characters)
287            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            // Check for trailing whitespace
302            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        // Run all validation checks
333        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        // Sort issues by line number, then by column
339        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        // Should have minimal issues (maybe style warnings)
373        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        // Debug print to see what issues we get
398        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        // Debug print to see what issues we get
413        for issue in &issues {
414            println!("Issue: {} - {}", issue.code, issue.message);
415        }
416        // This should find unclosed brackets
417        assert!(issues
418            .iter()
419            .any(|i| i.message.contains("bracket") || i.message.contains("Unclosed")));
420    }
421}