Skip to main content

oparry_validators/
imports.rs

1//! Import structure validator
2
3use crate::Validator;
4use oparry_core::{Issue, IssueLevel, Result, ValidationResult};
5use oparry_parser::{ParsedCode, Language};
6use regex::Regex;
7use std::collections::HashMap;
8use std::path::Path;
9
10/// Import configuration
11#[derive(Debug, Clone)]
12pub struct ImportConfig {
13    /// Enforce alias usage
14    pub enforce_alias: bool,
15    /// Alias mappings (e.g., "@/" -> "./src")
16    pub alias_map: HashMap<String, String>,
17    /// Require file extensions
18    pub require_extensions: bool,
19    /// Allowed import sources
20    pub allowed_sources: Vec<String>,
21}
22
23impl Default for ImportConfig {
24    fn default() -> Self {
25        let mut alias_map = HashMap::new();
26        alias_map.insert("@/".to_string(), "./src".to_string());
27        alias_map.insert("@/components".to_string(), "./components".to_string());
28        alias_map.insert("@/lib".to_string(), "./lib".to_string());
29
30        Self {
31            enforce_alias: true,
32            alias_map,
33            require_extensions: false,
34            allowed_sources: vec![
35                "react".to_string(),
36                "react-dom".to_string(),
37                "next".to_string(),
38                "@radix-ui/*".to_string(),
39                "class-variance-authority".to_string(),
40                "clsx".to_string(),
41                "tailwind-merge".to_string(),
42            ],
43        }
44    }
45}
46
47/// Import validator
48pub struct ImportValidator {
49    config: ImportConfig,
50    import_regex: Regex,
51    require_regex: Regex,
52}
53
54impl ImportValidator {
55    /// Create new import validator
56    pub fn new(config: ImportConfig) -> Self {
57        Self {
58            config,
59            // Match import statements - simplified to catch from... imports
60            import_regex: Regex::new(
61                r#"from\s+['"]([^'"]+)['"]"#
62            ).unwrap(),
63            // Match require statements
64            require_regex: Regex::new(r#"require\(['"]([^'"]+)['"]\)"#).unwrap(),
65        }
66    }
67
68    /// Create with default config
69    pub fn default_config() -> Self {
70        Self::new(ImportConfig::default())
71    }
72
73    /// Validate import path
74    fn validate_import_path(
75        &self,
76        path: &str,
77        file: &str,
78        line: usize,
79    ) -> Option<Issue> {
80        // Check file extensions first (for non-node_modules)
81        if self.config.require_extensions {
82            let is_node_module = !path.starts_with('.') && !path.starts_with('/');
83            if !is_node_module {
84                // Check if it has a valid file extension (not just the relative path dot)
85                let has_extension = path.ends_with(".ts")
86                    || path.ends_with(".tsx")
87                    || path.ends_with(".js")
88                    || path.ends_with(".jsx")
89                    || path.ends_with(".mts")
90                    || path.ends_with(".cjs")
91                    || path.ends_with(".mjs");
92                if !has_extension {
93                    return Some(Issue::error(
94                        "import-missing-extension",
95                        format!("Import '{}' is missing file extension", path),
96                    )
97                    .with_file(file)
98                    .with_line(line)
99                    .with_suggestion("Add file extension (e.g., '.ts', '.tsx')"));
100                }
101            }
102        }
103
104        // Check if it's a relative import - skip alias check for relative imports
105        if path.starts_with("./") || path.starts_with("../") {
106            return None;
107        }
108
109        // Check if it should use an alias
110        for (alias, _target) in &self.config.alias_map {
111            // This is simplified - real implementation would check if path
112            // matches the target and suggest using the alias instead
113            if path.contains("/src/") || path.contains("/components/") {
114                if self.config.enforce_alias {
115                    return Some(Issue::warning(
116                        "import-use-alias",
117                        format!("Import '{}' should use path alias", path),
118                    )
119                    .with_file(file)
120                    .with_line(line)
121                    .with_suggestion(&format!("Use {} instead", alias)));
122                }
123            }
124        }
125
126        None
127    }
128}
129
130impl Validator for ImportValidator {
131    fn name(&self) -> &str {
132        "Imports"
133    }
134
135    fn supports(&self, language: Language) -> bool {
136        language.is_javascript_variant()
137    }
138
139    fn validate_parsed(&self, code: &ParsedCode, file: &Path) -> Result<ValidationResult> {
140        let mut result = ValidationResult::new();
141        let source = code.source();
142
143        let file_str = file.to_string_lossy().to_string();
144
145        // Check imports
146        for (line_idx, line) in source.lines().enumerate() {
147            // ES imports
148            if let Some(caps) = self.import_regex.captures(line) {
149                if let Some(path) = caps.get(1) {
150                    let path_str = path.as_str();
151                    if let Some(issue) = self.validate_import_path(path_str, &file_str, line_idx) {
152                        result.add_issue(issue);
153                    }
154                }
155            }
156
157            // CommonJS requires
158            if let Some(caps) = self.require_regex.captures(line) {
159                if let Some(path) = caps.get(1) {
160                    let path_str = path.as_str();
161                    if let Some(issue) = self.validate_import_path(path_str, &file_str, line_idx) {
162                        result.add_issue(issue);
163                    }
164                }
165            }
166        }
167
168        Ok(result)
169    }
170
171    fn validate_raw(&self, source: &str, file: &Path) -> Result<ValidationResult> {
172        let parsed = ParsedCode::Generic(source.to_string());
173        self.validate_parsed(&parsed, file)
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn test_import_validator_valid() {
183        let validator = ImportValidator::default_config();
184        let code = r#"
185            import React from 'react';
186            import { Button } from '@/components/ui/button';
187            import { utils } from '@/lib/utils';
188        "#;
189
190        let result = validator.validate_raw(code, Path::new("test.ts")).unwrap();
191        assert!(result.passed);
192    }
193
194    #[test]
195    fn test_import_validator_relative() {
196        let validator = ImportValidator::default_config();
197        let code = r#"
198            import { Button } from './Button';
199            import { utils } from '../utils';
200        "#;
201
202        let result = validator.validate_raw(code, Path::new("test.ts")).unwrap();
203        assert!(result.passed); // Relative imports are OK
204    }
205
206    #[test]
207    fn test_import_config_default() {
208        let config = ImportConfig::default();
209        assert!(config.enforce_alias);
210        assert!(!config.require_extensions);
211        assert!(!config.allowed_sources.is_empty());
212    }
213
214    #[test]
215    fn test_import_config_alias_map() {
216        let config = ImportConfig::default();
217        assert!(config.alias_map.contains_key("@/"));
218        assert!(config.alias_map.contains_key("@/components"));
219        assert!(config.alias_map.contains_key("@/lib"));
220    }
221
222    #[test]
223    fn test_import_validator_require_extensions() {
224        let config = ImportConfig {
225            require_extensions: true,
226            ..Default::default()
227        };
228        let validator = ImportValidator::new(config);
229        let code = r#"
230            import { Component } from './Component';
231        "#;
232
233        let result = validator.validate_raw(code, Path::new("test.ts")).unwrap();
234        assert!(!result.passed, "Should fail with missing extension error");
235        assert_eq!(result.issues[0].code, "import-missing-extension");
236    }
237
238    #[test]
239    fn test_import_validator_node_modules() {
240        let config = ImportConfig {
241            require_extensions: true,
242            ..Default::default()
243        };
244        let validator = ImportValidator::new(config);
245        let code = r#"
246            import React from 'react';
247            import { useState } from 'react';
248        "#;
249
250        let result = validator.validate_raw(code, Path::new("test.ts")).unwrap();
251        // Node modules don't need extensions
252        assert!(result.passed);
253    }
254
255    #[test]
256    fn test_import_validator_commonjs() {
257        let validator = ImportValidator::default_config();
258        let code = r#"
259            const React = require('react');
260            const utils = require('./utils');
261        "#;
262
263        let result = validator.validate_raw(code, Path::new("test.js")).unwrap();
264        assert!(result.passed);
265    }
266
267    #[test]
268    fn test_import_validator_supports() {
269        let validator = ImportValidator::default_config();
270        assert!(validator.supports(Language::JavaScript));
271        assert!(validator.supports(Language::TypeScript));
272        assert!(validator.supports(Language::Jsx));
273        assert!(validator.supports(Language::Tsx));
274        assert!(!validator.supports(Language::Rust));
275    }
276
277    #[test]
278    fn test_import_config_custom_alias_map() {
279        let mut alias_map = std::collections::HashMap::new();
280        alias_map.insert("@lib".to_string(), "./lib".to_string());
281
282        let config = ImportConfig {
283            alias_map,
284            enforce_alias: true,
285            ..Default::default()
286        };
287
288        assert!(config.alias_map.contains_key("@lib"));
289    }
290
291    #[test]
292    fn test_import_multiple_issues() {
293        let validator = ImportValidator::default_config();
294        let code = r#"
295            import React from 'react';
296            import { Button } from './Button';
297            import { utils } from '../utils';
298        "#;
299
300        let result = validator.validate_raw(code, Path::new("test.ts")).unwrap();
301        // All imports should be valid (relative or standard)
302        assert!(result.passed);
303    }
304}