Skip to main content

rez_lsp_server/validation/
rez_validator.rs

1//! Rez-specific validation for package.py files.
2
3use super::{Severity, ValidationIssue, Validator};
4use crate::core::{types::Version, Result};
5use regex::Regex;
6use std::collections::{HashMap, HashSet};
7
8/// Validates Rez-specific syntax and semantics in package.py files.
9pub struct RezValidator {
10    /// Required fields for a valid Rez package
11    required_fields: HashSet<String>,
12    /// Optional but recommended fields
13    recommended_fields: HashSet<String>,
14    /// Deprecated fields that should be avoided
15    deprecated_fields: HashMap<String, String>,
16    /// Regex patterns for validation
17    patterns: RezPatterns,
18}
19
20struct RezPatterns {
21    /// Pattern for version strings
22    version_pattern: Regex,
23    /// Pattern for package names
24    name_pattern: Regex,
25    /// Pattern for requirement strings
26    requirement_pattern: Regex,
27    /// Pattern for tool definitions
28    #[allow(dead_code)]
29    tool_pattern: Regex,
30}
31
32impl RezValidator {
33    /// Create a new Rez validator.
34    pub fn new() -> Result<Self> {
35        let mut required_fields = HashSet::new();
36        required_fields.insert("name".to_string());
37        required_fields.insert("version".to_string());
38
39        let mut recommended_fields = HashSet::new();
40        recommended_fields.insert("description".to_string());
41        recommended_fields.insert("authors".to_string());
42        recommended_fields.insert("requires".to_string());
43
44        let mut deprecated_fields = HashMap::new();
45        deprecated_fields.insert(
46            "uuid".to_string(),
47            "UUIDs are no longer used in Rez packages".to_string(),
48        );
49        deprecated_fields.insert(
50            "config".to_string(),
51            "Use 'private_build_requires' instead".to_string(),
52        );
53
54        let patterns = RezPatterns {
55            version_pattern: Regex::new(r"^[0-9]+(\.[0-9]+)*([a-zA-Z][a-zA-Z0-9]*)?$")?,
56            name_pattern: Regex::new(r"^[a-zA-Z][a-zA-Z0-9_]*$")?,
57            requirement_pattern: Regex::new(r"^[a-zA-Z][a-zA-Z0-9_]*([<>=!]+[0-9]+(\.[0-9]+)*)?$")?,
58            tool_pattern: Regex::new(r"^[a-zA-Z][a-zA-Z0-9_]*$")?,
59        };
60
61        Ok(Self {
62            required_fields,
63            recommended_fields,
64            deprecated_fields,
65            patterns,
66        })
67    }
68
69    /// Extract field assignments from Python code.
70    fn extract_fields(&self, content: &str) -> HashMap<String, (u32, String)> {
71        let mut fields = HashMap::new();
72        let assignment_regex = Regex::new(r"^(\w+)\s*=\s*(.+)$").unwrap();
73
74        for (line_num, line) in content.lines().enumerate() {
75            let line_num = line_num as u32 + 1;
76            let trimmed = line.trim();
77
78            // Skip comments and empty lines
79            if trimmed.is_empty() || trimmed.starts_with('#') {
80                continue;
81            }
82
83            if let Some(captures) = assignment_regex.captures(trimmed) {
84                let field_name = captures.get(1).unwrap().as_str().to_string();
85                let field_value = captures.get(2).unwrap().as_str().to_string();
86                fields.insert(field_name, (line_num, field_value));
87            }
88        }
89
90        fields
91    }
92
93    /// Validate required fields.
94    fn check_required_fields(
95        &self,
96        fields: &HashMap<String, (u32, String)>,
97    ) -> Vec<ValidationIssue> {
98        let mut issues = Vec::new();
99
100        for required_field in &self.required_fields {
101            if !fields.contains_key(required_field) {
102                issues.push(
103                    ValidationIssue::new(
104                        Severity::Error,
105                        1,
106                        1,
107                        1,
108                        format!("Missing required field '{}'", required_field),
109                        "R001",
110                    )
111                    .with_suggestion(format!(
112                        "Add '{}' field to the package definition",
113                        required_field
114                    )),
115                );
116            }
117        }
118
119        issues
120    }
121
122    /// Validate recommended fields.
123    fn check_recommended_fields(
124        &self,
125        fields: &HashMap<String, (u32, String)>,
126    ) -> Vec<ValidationIssue> {
127        let mut issues = Vec::new();
128
129        for recommended_field in &self.recommended_fields {
130            if !fields.contains_key(recommended_field) {
131                issues.push(
132                    ValidationIssue::new(
133                        Severity::Warning,
134                        1,
135                        1,
136                        1,
137                        format!("Missing recommended field '{}'", recommended_field),
138                        "R101",
139                    )
140                    .with_suggestion(format!(
141                        "Consider adding '{}' field for better package documentation",
142                        recommended_field
143                    )),
144                );
145            }
146        }
147
148        issues
149    }
150
151    /// Validate deprecated fields.
152    fn check_deprecated_fields(
153        &self,
154        fields: &HashMap<String, (u32, String)>,
155    ) -> Vec<ValidationIssue> {
156        let mut issues = Vec::new();
157
158        for (field_name, (line_num, _)) in fields {
159            if let Some(reason) = self.deprecated_fields.get(field_name) {
160                issues.push(
161                    ValidationIssue::new(
162                        Severity::Warning,
163                        *line_num,
164                        1,
165                        field_name.len() as u32,
166                        format!("Deprecated field '{}': {}", field_name, reason),
167                        "R201",
168                    )
169                    .with_suggestion("Remove this deprecated field"),
170                );
171            }
172        }
173
174        issues
175    }
176
177    /// Validate package name.
178    fn validate_name(&self, fields: &HashMap<String, (u32, String)>) -> Vec<ValidationIssue> {
179        let mut issues = Vec::new();
180
181        if let Some((line_num, value)) = fields.get("name") {
182            let clean_value = self.clean_string_value(value);
183
184            if !self.patterns.name_pattern.is_match(&clean_value) {
185                issues.push(ValidationIssue::new(
186                    Severity::Error,
187                    *line_num,
188                    1,
189                    value.len() as u32,
190                    "Invalid package name format",
191                    "R002",
192                ).with_suggestion("Package names must start with a letter and contain only letters, numbers, and underscores"));
193            }
194
195            // Check for reserved names
196            let reserved_names = ["test", "build", "install", "package"];
197            if reserved_names.contains(&clean_value.as_str()) {
198                issues.push(
199                    ValidationIssue::new(
200                        Severity::Warning,
201                        *line_num,
202                        1,
203                        value.len() as u32,
204                        format!("Package name '{}' is a reserved word", clean_value),
205                        "R102",
206                    )
207                    .with_suggestion("Consider using a different package name"),
208                );
209            }
210        }
211
212        issues
213    }
214
215    /// Validate version field.
216    fn validate_version(&self, fields: &HashMap<String, (u32, String)>) -> Vec<ValidationIssue> {
217        let mut issues = Vec::new();
218
219        if let Some((line_num, value)) = fields.get("version") {
220            let clean_value = self.clean_string_value(value);
221
222            // Try to parse as Rez version
223            match Version::new(&clean_value) {
224                version if version.tokens.is_empty() => {
225                    issues.push(
226                        ValidationIssue::new(
227                            Severity::Error,
228                            *line_num,
229                            1,
230                            value.len() as u32,
231                            "Invalid version format",
232                            "R003",
233                        )
234                        .with_suggestion("Use semantic versioning (e.g., '1.0.0')"),
235                    );
236                }
237                _ => {
238                    // Version is valid, check for best practices
239                    if !self.patterns.version_pattern.is_match(&clean_value) {
240                        issues.push(
241                            ValidationIssue::new(
242                                Severity::Warning,
243                                *line_num,
244                                1,
245                                value.len() as u32,
246                                "Version format doesn't follow semantic versioning",
247                                "R103",
248                            )
249                            .with_suggestion(
250                                "Consider using semantic versioning (major.minor.patch)",
251                            ),
252                        );
253                    }
254                }
255            }
256        }
257
258        issues
259    }
260
261    /// Validate requires field.
262    fn validate_requires(&self, fields: &HashMap<String, (u32, String)>) -> Vec<ValidationIssue> {
263        let mut issues = Vec::new();
264
265        if let Some((line_num, value)) = fields.get("requires") {
266            // Parse the requires list
267            if let Some(requirements) = self.parse_list_value(value) {
268                for requirement in requirements.iter() {
269                    let clean_req = self.clean_string_value(requirement);
270
271                    // Validate requirement format
272                    if !self.patterns.requirement_pattern.is_match(&clean_req) {
273                        issues.push(
274                            ValidationIssue::new(
275                                Severity::Error,
276                                *line_num,
277                                1,
278                                requirement.len() as u32,
279                                format!("Invalid requirement format: '{}'", clean_req),
280                                "R004",
281                            )
282                            .with_suggestion(
283                                "Requirements should be in format 'package' or 'package>=1.0.0'",
284                            ),
285                        );
286                    }
287
288                    // Check for common typos
289                    let common_packages = ["python", "maya", "houdini", "nuke", "blender"];
290                    if !common_packages
291                        .iter()
292                        .any(|&pkg| clean_req.starts_with(pkg))
293                    {
294                        // This is a custom package, check for naming conventions
295                        if clean_req.contains('-') {
296                            issues.push(
297                                ValidationIssue::new(
298                                    Severity::Warning,
299                                    *line_num,
300                                    1,
301                                    requirement.len() as u32,
302                                    "Package names with hyphens may cause issues",
303                                    "R104",
304                                )
305                                .with_suggestion("Consider using underscores instead of hyphens"),
306                            );
307                        }
308                    }
309                }
310
311                // Check for duplicate requirements
312                let mut seen = HashSet::new();
313                for requirement in &requirements {
314                    let clean_req = self.clean_string_value(requirement);
315                    let package_name = clean_req
316                        .split(&['<', '>', '=', '!'][..])
317                        .next()
318                        .unwrap_or(&clean_req)
319                        .to_string();
320
321                    if !seen.insert(package_name.clone()) {
322                        issues.push(
323                            ValidationIssue::new(
324                                Severity::Warning,
325                                *line_num,
326                                1,
327                                value.len() as u32,
328                                format!("Duplicate requirement: '{}'", package_name),
329                                "R105",
330                            )
331                            .with_suggestion("Remove duplicate requirements"),
332                        );
333                    }
334                }
335            }
336        }
337
338        issues
339    }
340
341    /// Validate tools field.
342    fn validate_tools(&self, fields: &HashMap<String, (u32, String)>) -> Vec<ValidationIssue> {
343        let mut issues = Vec::new();
344
345        if let Some((line_num, value)) = fields.get("tools") {
346            // Basic validation for tools dictionary format
347            if !value.trim().starts_with('{') || !value.trim().ends_with('}') {
348                issues.push(
349                    ValidationIssue::new(
350                        Severity::Error,
351                        *line_num,
352                        1,
353                        value.len() as u32,
354                        "Tools field must be a dictionary",
355                        "R005",
356                    )
357                    .with_suggestion("Use dictionary format: tools = {'tool_name': 'tool_path'}"),
358                );
359            }
360        }
361
362        issues
363    }
364
365    /// Clean string values by removing quotes.
366    fn clean_string_value(&self, value: &str) -> String {
367        value
368            .trim()
369            .trim_start_matches('"')
370            .trim_end_matches('"')
371            .trim_start_matches('\'')
372            .trim_end_matches('\'')
373            .to_string()
374    }
375
376    /// Parse a list value from Python syntax.
377    fn parse_list_value(&self, value: &str) -> Option<Vec<String>> {
378        let trimmed = value.trim();
379        if !trimmed.starts_with('[') || !trimmed.ends_with(']') {
380            return None;
381        }
382
383        let content = &trimmed[1..trimmed.len() - 1];
384        let items: Vec<String> = content
385            .split(',')
386            .map(|s| s.trim().to_string())
387            .filter(|s| !s.is_empty())
388            .collect();
389
390        Some(items)
391    }
392}
393
394impl Default for RezValidator {
395    fn default() -> Self {
396        Self::new().expect("Failed to create RezValidator")
397    }
398}
399
400impl Validator for RezValidator {
401    fn validate(&self, content: &str, _file_path: &str) -> Result<Vec<ValidationIssue>> {
402        let mut issues = Vec::new();
403
404        // Extract field assignments
405        let fields = self.extract_fields(content);
406
407        // Run all Rez-specific validations
408        issues.extend(self.check_required_fields(&fields));
409        issues.extend(self.check_recommended_fields(&fields));
410        issues.extend(self.check_deprecated_fields(&fields));
411        issues.extend(self.validate_name(&fields));
412        issues.extend(self.validate_version(&fields));
413        issues.extend(self.validate_requires(&fields));
414        issues.extend(self.validate_tools(&fields));
415
416        // Sort issues by line number
417        issues.sort_by_key(|issue| issue.line);
418
419        Ok(issues)
420    }
421
422    fn name(&self) -> &str {
423        "RezValidator"
424    }
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430
431    #[test]
432    fn test_rez_validator_creation() {
433        let validator = RezValidator::new();
434        assert!(validator.is_ok());
435    }
436
437    #[test]
438    fn test_valid_rez_package() {
439        let validator = RezValidator::new().unwrap();
440        let content = r#"
441name = "test_package"
442version = "1.0.0"
443description = "A test package"
444authors = ["Test Author"]
445requires = ["python>=3.7"]
446"#;
447
448        let issues = validator.validate(content, "package.py").unwrap();
449        // Should have no errors, maybe some warnings
450        assert!(issues.iter().all(|i| i.severity != Severity::Error));
451    }
452
453    #[test]
454    fn test_missing_required_fields() {
455        let validator = RezValidator::new().unwrap();
456        let content = r#"
457description = "A test package"
458"#;
459
460        let issues = validator.validate(content, "package.py").unwrap();
461        assert!(issues
462            .iter()
463            .any(|i| i.code == "R001" && i.message.contains("name")));
464        assert!(issues
465            .iter()
466            .any(|i| i.code == "R001" && i.message.contains("version")));
467    }
468
469    #[test]
470    fn test_invalid_package_name() {
471        let validator = RezValidator::new().unwrap();
472        let content = r#"
473name = "123invalid"
474version = "1.0.0"
475"#;
476
477        let issues = validator.validate(content, "package.py").unwrap();
478        assert!(issues.iter().any(|i| i.code == "R002"));
479    }
480
481    #[test]
482    fn test_deprecated_fields() {
483        let validator = RezValidator::new().unwrap();
484        let content = r#"
485name = "test"
486version = "1.0.0"
487uuid = "some-uuid"
488"#;
489
490        let issues = validator.validate(content, "package.py").unwrap();
491        assert!(issues.iter().any(|i| i.code == "R201"));
492    }
493}