oparry_validators/
imports.rs1use 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#[derive(Debug, Clone)]
12pub struct ImportConfig {
13 pub enforce_alias: bool,
15 pub alias_map: HashMap<String, String>,
17 pub require_extensions: bool,
19 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
47pub struct ImportValidator {
49 config: ImportConfig,
50 import_regex: Regex,
51 require_regex: Regex,
52}
53
54impl ImportValidator {
55 pub fn new(config: ImportConfig) -> Self {
57 Self {
58 config,
59 import_regex: Regex::new(
61 r#"from\s+['"]([^'"]+)['"]"#
62 ).unwrap(),
63 require_regex: Regex::new(r#"require\(['"]([^'"]+)['"]\)"#).unwrap(),
65 }
66 }
67
68 pub fn default_config() -> Self {
70 Self::new(ImportConfig::default())
71 }
72
73 fn validate_import_path(
75 &self,
76 path: &str,
77 file: &str,
78 line: usize,
79 ) -> Option<Issue> {
80 if self.config.require_extensions {
82 let is_node_module = !path.starts_with('.') && !path.starts_with('/');
83 if !is_node_module {
84 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 if path.starts_with("./") || path.starts_with("../") {
106 return None;
107 }
108
109 for (alias, _target) in &self.config.alias_map {
111 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 for (line_idx, line) in source.lines().enumerate() {
147 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 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); }
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 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 assert!(result.passed);
303 }
304}