1use std::path::{Component, Path};
9
10use regex::Regex;
11use std::sync::OnceLock;
12
13pub(crate) fn extension_for_language(language: &str) -> Option<&'static str> {
15 match language {
16 "rust" => Some("rs"),
17 "python" => Some("py"),
18 "typescript" => Some("ts"),
19 _ => None,
20 }
21}
22
23fn rust_use_re() -> &'static Regex {
24 static RE: OnceLock<Regex> = OnceLock::new();
25 RE.get_or_init(|| {
26 Regex::new(r"(?m)^\s*(?:pub(?:\([^)]*\))?\s+)?use\s+([A-Za-z_][A-Za-z0-9_:]*)").unwrap()
27 })
28}
29
30fn rust_extern_crate_re() -> &'static Regex {
31 static RE: OnceLock<Regex> = OnceLock::new();
32 RE.get_or_init(|| Regex::new(r"(?m)^\s*extern\s+crate\s+([A-Za-z_][A-Za-z0-9_]*)").unwrap())
33}
34
35fn python_import_re() -> &'static Regex {
36 static RE: OnceLock<Regex> = OnceLock::new();
37 RE.get_or_init(|| Regex::new(r"(?m)^\s*import\s+([A-Za-z_][A-Za-z0-9_.]*)").unwrap())
38}
39
40fn python_from_re() -> &'static Regex {
41 static RE: OnceLock<Regex> = OnceLock::new();
42 RE.get_or_init(|| Regex::new(r"(?m)^\s*from\s+([.]*[A-Za-z0-9_.]*)\s+import").unwrap())
43}
44
45fn ts_import_re() -> &'static Regex {
46 static RE: OnceLock<Regex> = OnceLock::new();
47 RE.get_or_init(|| Regex::new(r#"import(?:[^'";]*?from)?\s*['"]([^'"]+)['"]"#).unwrap())
48}
49
50fn ts_require_re() -> &'static Regex {
51 static RE: OnceLock<Regex> = OnceLock::new();
52 RE.get_or_init(|| Regex::new(r#"require\(\s*['"]([^'"]+)['"]\s*\)"#).unwrap())
53}
54
55pub(crate) fn extract_raw_imports(language: &str, content: &str) -> Vec<String> {
57 let mut out = Vec::new();
58 match language {
59 "rust" => {
60 for c in rust_use_re().captures_iter(content) {
61 out.push(c[1].trim_end_matches(':').to_string());
62 }
63 for c in rust_extern_crate_re().captures_iter(content) {
64 out.push(c[1].to_string());
65 }
66 }
67 "python" => {
68 for c in python_import_re().captures_iter(content) {
69 out.push(c[1].to_string());
70 }
71 for c in python_from_re().captures_iter(content) {
72 out.push(c[1].to_string());
73 }
74 }
75 "typescript" => {
76 for c in ts_import_re().captures_iter(content) {
77 out.push(c[1].to_string());
78 }
79 for c in ts_require_re().captures_iter(content) {
80 out.push(c[1].to_string());
81 }
82 }
83 _ => {}
84 }
85 out
86}
87
88#[derive(Debug, Clone, PartialEq, Eq)]
91pub(crate) enum Resolved {
92 IntraModule(String),
95 ExternalProject(String),
98 Skip,
100}
101
102const RUST_STD_CRATES: &[&str] = &["std", "core", "alloc", "proc_macro", "test"];
103const PY_STDLIB: &[&str] = &[
104 "os",
105 "sys",
106 "re",
107 "json",
108 "typing",
109 "collections",
110 "itertools",
111 "functools",
112 "pathlib",
113 "subprocess",
114 "logging",
115 "unittest",
116 "abc",
117 "dataclasses",
118 "enum",
119 "math",
120 "random",
121 "datetime",
122 "io",
123 "argparse",
124 "asyncio",
125];
126
127fn normalize_crate_name(name: &str) -> String {
128 name.replace('-', "_")
129}
130
131pub(crate) fn classify_import(
132 language: &str,
133 raw: &str,
134 current_module_path: &str,
135 project_name: &str,
136) -> Resolved {
137 match language {
138 "rust" => classify_rust(raw, current_module_path, project_name),
139 "python" => classify_python(raw, current_module_path, project_name),
140 "typescript" => classify_typescript(raw),
141 _ => Resolved::Skip,
142 }
143}
144
145fn classify_rust(raw: &str, current_module_path: &str, project_name: &str) -> Resolved {
146 if let Some(rest) = raw.strip_prefix("crate::") {
147 return module_or_skip(rest);
148 }
149 if raw.starts_with("self::") || raw == "self" {
150 return Resolved::Skip;
151 }
152 if let Some(rest) = raw.strip_prefix("super::") {
153 let mut parent: Vec<&str> = current_module_path.split("::").collect();
154 parent.pop();
155 if parent.is_empty() {
156 return module_or_skip(rest);
157 }
158 return Resolved::IntraModule(format!("{}::{}", parent.join("::"), rest));
159 }
160 let mut segments = raw.splitn(2, "::");
161 let first = segments.next().unwrap_or_default();
162 if first.is_empty() || first == "crate" {
163 return Resolved::Skip;
164 }
165 if RUST_STD_CRATES.contains(&first) {
166 return Resolved::Skip;
167 }
168 if normalize_crate_name(first) == normalize_crate_name(project_name) {
169 return module_or_skip(segments.next().unwrap_or(""));
170 }
171 Resolved::ExternalProject(first.to_string())
172}
173
174fn module_or_skip(module_path: &str) -> Resolved {
175 if module_path.is_empty() {
176 Resolved::Skip
177 } else {
178 Resolved::IntraModule(module_path.to_string())
179 }
180}
181
182fn classify_python(raw: &str, current_module_path: &str, project_name: &str) -> Resolved {
183 if let Some(stripped) = raw.strip_prefix('.') {
184 let mut level = 1usize;
185 let mut rest = stripped;
186 while let Some(s) = rest.strip_prefix('.') {
187 level += 1;
188 rest = s;
189 }
190 let mut base: Vec<&str> = current_module_path.split('.').collect();
191 base.pop();
194 for _ in 1..level {
195 base.pop();
196 }
197 if rest.is_empty() {
198 return module_or_skip(&base.join("."));
199 }
200 if base.is_empty() {
201 return Resolved::IntraModule(rest.to_string());
202 }
203 return Resolved::IntraModule(format!("{}.{}", base.join("."), rest));
204 }
205 if raw.is_empty() {
206 return Resolved::Skip;
207 }
208 let first = raw.split('.').next().unwrap_or_default();
209 if PY_STDLIB.contains(&first) {
210 return Resolved::Skip;
211 }
212 let normalized_first = first.replace('-', "_");
213 let normalized_project = project_name.replace('-', "_");
214 if normalized_first == normalized_project {
215 return Resolved::IntraModule(raw.to_string());
216 }
217 Resolved::ExternalProject(first.to_string())
218}
219
220fn classify_typescript(raw: &str) -> Resolved {
221 if raw.starts_with('.') {
222 return Resolved::IntraModule(raw.to_string());
223 }
224 if raw.starts_with('@') {
225 let mut parts = raw.splitn(3, '/');
226 let scope = parts.next().unwrap_or_default();
227 let pkg = parts.next().unwrap_or_default();
228 if pkg.is_empty() {
229 return Resolved::Skip;
230 }
231 return Resolved::ExternalProject(format!("{scope}/{pkg}"));
232 }
233 let first = raw.split('/').next().unwrap_or_default();
234 if first.is_empty() {
235 return Resolved::Skip;
236 }
237 Resolved::ExternalProject(first.to_string())
238}
239
240pub(crate) fn resolve_relative_ts_module(current_file_dir_rel: &Path, specifier: &str) -> String {
244 let mut components: Vec<String> = current_file_dir_rel
245 .components()
246 .filter_map(|c| match c {
247 Component::Normal(s) => Some(s.to_string_lossy().to_string()),
248 _ => None,
249 })
250 .collect();
251 for part in specifier.split('/') {
252 match part {
253 "" | "." => {}
254 ".." => {
255 components.pop();
256 }
257 other => components.push(other.to_string()),
258 }
259 }
260 let joined = components.join("/");
261 joined
262 .strip_suffix(".ts")
263 .or_else(|| joined.strip_suffix(".tsx"))
264 .unwrap_or(&joined)
265 .to_string()
266}
267
268pub(crate) fn module_path_for_file(
272 file: &Path,
273 project_root: &Path,
274 language: &str,
275) -> Option<String> {
276 let rel = file.strip_prefix(project_root).ok()?;
277 let mut components: Vec<String> = rel
278 .components()
279 .filter_map(|c| match c {
280 Component::Normal(s) => Some(s.to_string_lossy().to_string()),
281 _ => None,
282 })
283 .collect();
284 if language == "rust" && components.first().map(String::as_str) == Some("src") {
288 components.remove(0);
289 }
290 if components.is_empty() {
291 return None;
292 }
293
294 match language {
295 "rust" => {
296 let stem = components.last()?.strip_suffix(".rs")?.to_string();
297 let is_root = components.len() == 1 && (stem == "lib" || stem == "main");
298 *components.last_mut()? = stem.clone();
299 if is_root {
300 return Some("crate".to_string());
301 }
302 if stem == "mod" {
303 components.pop();
304 if components.is_empty() {
305 return Some("crate".to_string());
306 }
307 }
308 Some(components.join("::"))
309 }
310 "python" => {
311 let stem = components.last()?.strip_suffix(".py")?.to_string();
312 *components.last_mut()? = stem.clone();
313 if stem == "__init__" {
314 components.pop();
315 if components.is_empty() {
316 return None;
317 }
318 }
319 Some(components.join("."))
320 }
321 "typescript" => {
322 let last = components.last()?;
323 let stripped = last
324 .strip_suffix(".ts")
325 .or_else(|| last.strip_suffix(".tsx"))?
326 .to_string();
327 *components.last_mut()? = stripped;
328 Some(components.join("/"))
329 }
330 _ => None,
331 }
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337
338 #[test]
339 fn rust_use_extracts_crate_path() {
340 let content = "use serde::Serialize;\nuse crate::foo::Bar;\n";
341 let raw = extract_raw_imports("rust", content);
342 assert!(raw.contains(&"serde::Serialize".to_string()));
343 assert!(raw.contains(&"crate::foo::Bar".to_string()));
344 }
345
346 #[test]
347 fn rust_classify_external_vs_intra() {
348 assert_eq!(
349 classify_import("rust", "serde::Serialize", "crate", "mycrate"),
350 Resolved::ExternalProject("serde".to_string())
351 );
352 assert_eq!(
353 classify_import("rust", "mycrate::foo::Bar", "crate", "mycrate"),
354 Resolved::IntraModule("foo::Bar".to_string())
355 );
356 assert_eq!(
357 classify_import("rust", "std::collections::HashMap", "crate", "mycrate"),
358 Resolved::Skip
359 );
360 }
361
362 #[test]
363 fn python_classify_relative_import() {
364 assert_eq!(
365 classify_import("python", ".sibling", "pkg.mod", "pkg"),
366 Resolved::IntraModule("pkg.sibling".to_string())
367 );
368 assert_eq!(
369 classify_import("python", "requests", "pkg.mod", "pkg"),
370 Resolved::ExternalProject("requests".to_string())
371 );
372 }
373
374 #[test]
375 fn typescript_classify_relative_vs_package() {
376 assert_eq!(
377 classify_import("typescript", "./util", "", ""),
378 Resolved::IntraModule("./util".to_string())
379 );
380 assert_eq!(
381 classify_import("typescript", "left-pad", "", ""),
382 Resolved::ExternalProject("left-pad".to_string())
383 );
384 }
385
386 #[test]
387 fn module_path_for_rust_lib_root_is_crate() {
388 let root = Path::new("/proj");
389 let file = Path::new("/proj/src/lib.rs");
390 assert_eq!(
391 module_path_for_file(file, root, "rust"),
392 Some("crate".to_string())
393 );
394 }
395
396 #[test]
397 fn module_path_for_rust_nested_module() {
398 let root = Path::new("/proj");
399 let file = Path::new("/proj/src/foo/bar.rs");
400 assert_eq!(
401 module_path_for_file(file, root, "rust"),
402 Some("foo::bar".to_string())
403 );
404 }
405
406 #[test]
407 fn module_path_for_python_init_uses_parent() {
408 let root = Path::new("/proj");
409 let file = Path::new("/proj/pkg/__init__.py");
410 assert_eq!(
411 module_path_for_file(file, root, "python"),
412 Some("pkg".to_string())
413 );
414 }
415}