1use regex::Regex;
2use std::collections::BTreeSet;
3
4#[cfg(feature = "tree-sitter")]
5use super::deep_queries::{self, ImportKind};
6
7macro_rules! static_regex {
8 ($pattern:expr_2021) => {{
9 static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
10 RE.get_or_init(|| {
11 regex::Regex::new($pattern).expect(concat!("BUG: invalid static regex: ", $pattern))
12 })
13 }};
14}
15
16fn import_re() -> &'static Regex {
17 static_regex!(r#"import\s+(?:\{[^}]*\}\s+from\s+|.*from\s+)['"]([^'"]+)['"]"#)
18}
19fn require_re() -> &'static Regex {
20 static_regex!(r#"require\(['"]([^'"]+)['"]\)"#)
21}
22fn rust_use_re() -> &'static Regex {
23 static_regex!(r"^use\s+([\w:]+)")
24}
25fn py_import_re() -> &'static Regex {
26 static_regex!(r"^(?:from\s+(\S+)\s+import|import\s+(\S+))")
27}
28fn go_import_re() -> &'static Regex {
29 static_regex!(r#""([^"]+)""#)
30}
31
32#[derive(Debug, Clone)]
33pub struct DepInfo {
34 pub imports: Vec<String>,
35 pub exports: Vec<String>,
36}
37
38pub fn extract_deps(content: &str, ext: &str) -> DepInfo {
39 let lang = crate::core::language_capabilities::language_for_ext(ext);
40 match lang {
41 Some(
42 crate::core::language_capabilities::LanguageId::TypeScript
43 | crate::core::language_capabilities::LanguageId::JavaScript
44 | crate::core::language_capabilities::LanguageId::Vue
45 | crate::core::language_capabilities::LanguageId::Svelte,
46 ) => extract_ts_deps(content),
47 Some(crate::core::language_capabilities::LanguageId::Rust) => extract_rust_deps(content),
48 Some(crate::core::language_capabilities::LanguageId::Python) => {
49 extract_python_deps(content)
50 }
51 Some(crate::core::language_capabilities::LanguageId::Go) => extract_go_deps(content),
52 Some(
53 crate::core::language_capabilities::LanguageId::C
54 | crate::core::language_capabilities::LanguageId::Cpp,
55 ) => extract_c_like_deps(content),
56 Some(crate::core::language_capabilities::LanguageId::Ruby) => extract_ruby_deps(content),
57 Some(crate::core::language_capabilities::LanguageId::Php) => extract_php_deps(content),
58 Some(crate::core::language_capabilities::LanguageId::Bash) => extract_bash_deps(content),
59 Some(crate::core::language_capabilities::LanguageId::Kotlin) => {
60 extract_kotlin_deps(content)
61 }
62 Some(crate::core::language_capabilities::LanguageId::Dart) => {
63 let mut imports = BTreeSet::new();
64 let re = static_regex!(r#"^\s*(?:import|export|part)\s+['"]([^'"]+)['"]"#);
65 for line in content.lines() {
66 let trimmed = line.trim();
67 if let Some(caps) = re.captures(trimmed) {
68 let p = caps[1].trim();
69 if p.starts_with('.') || p.starts_with('/') {
70 imports.insert(clean_path_like(p));
71 }
72 }
73 }
74 DepInfo {
75 imports: imports.into_iter().collect(),
76 exports: Vec::new(),
77 }
78 }
79 Some(crate::core::language_capabilities::LanguageId::Zig) => {
80 let mut imports = BTreeSet::new();
81 let re = static_regex!(r#"@import\(\s*"([^"]+)"\s*\)"#);
82 for line in content.lines() {
83 let trimmed = line.trim();
84 if let Some(caps) = re.captures(trimmed) {
85 let p = caps[1].trim();
86 if p.starts_with('.')
87 || p.contains('/')
88 || std::path::Path::new(p)
89 .extension()
90 .is_some_and(|e| e.eq_ignore_ascii_case("zig"))
91 {
92 imports.insert(clean_path_like(p));
93 }
94 }
95 }
96 DepInfo {
97 imports: imports.into_iter().collect(),
98 exports: Vec::new(),
99 }
100 }
101 _ => DepInfo {
102 imports: Vec::new(),
103 exports: Vec::new(),
104 },
105 }
106}
107
108fn extract_ts_deps(content: &str) -> DepInfo {
109 let mut imports = BTreeSet::new();
110 let mut exports = Vec::new();
111
112 for line in content.lines() {
113 let trimmed = line.trim();
114
115 if let Some(caps) = import_re().captures(trimmed) {
116 let path = &caps[1];
117 if path.starts_with('.') || path.starts_with('/') {
118 imports.insert(clean_import_path(path));
119 }
120 }
121 if let Some(caps) = require_re().captures(trimmed) {
122 let path = &caps[1];
123 if path.starts_with('.') || path.starts_with('/') {
124 imports.insert(clean_import_path(path));
125 }
126 }
127
128 if trimmed.starts_with("export ")
129 && let Some(name) = extract_export_name(trimmed)
130 {
131 exports.push(name);
132 }
133 }
134
135 DepInfo {
136 imports: imports.into_iter().collect(),
137 exports,
138 }
139}
140
141fn extract_rust_deps(content: &str) -> DepInfo {
142 let mut imports = BTreeSet::new();
143 let mut exports = Vec::new();
144
145 for line in content.lines() {
146 let trimmed = line.trim();
147
148 if let Some(caps) = rust_use_re().captures(trimmed) {
149 let path = &caps[1];
150 if !path.starts_with("std::") && !path.starts_with("core::") {
151 imports.insert(path.to_string());
152 }
153 }
154
155 let item = trimmed
158 .strip_prefix("pub fn ")
159 .or_else(|| trimmed.strip_prefix("pub async fn "))
160 .or_else(|| trimmed.strip_prefix("pub struct "))
161 .or_else(|| trimmed.strip_prefix("pub enum "))
162 .or_else(|| trimmed.strip_prefix("pub trait "));
163 if let Some(rest) = item {
164 let name = leading_identifier(rest.trim_start());
165 if !name.is_empty() {
166 exports.push(name.to_string());
167 }
168 }
169 }
170
171 DepInfo {
172 imports: imports.into_iter().collect(),
173 exports,
174 }
175}
176
177fn leading_identifier(s: &str) -> &str {
180 let body_start = if s.starts_with("r#") { 2 } else { 0 };
181 let end = s[body_start..]
182 .find(|c: char| !(c.is_alphanumeric() || c == '_'))
183 .map_or(s.len(), |i| body_start + i);
184 if end == body_start { "" } else { &s[..end] }
185}
186
187fn extract_python_deps(content: &str) -> DepInfo {
188 let mut imports = BTreeSet::new();
189 let mut exports = Vec::new();
190
191 for line in content.lines() {
192 let trimmed = line.trim();
193
194 if let Some(caps) = py_import_re().captures(trimmed)
195 && let Some(m) = caps.get(1).or(caps.get(2))
196 {
197 let module = m.as_str();
198 if !module.starts_with("os")
199 && !module.starts_with("sys")
200 && !module.starts_with("json")
201 {
202 imports.insert(module.to_string());
203 }
204 }
205
206 if trimmed.starts_with("def ") && !trimmed.contains('_') {
207 if let Some(name) = trimmed
208 .strip_prefix("def ")
209 .and_then(|s| s.split('(').next())
210 {
211 exports.push(name.to_string());
212 }
213 } else if trimmed.starts_with("class ")
214 && let Some(name) = trimmed
215 .strip_prefix("class ")
216 .and_then(|s| s.split(['(', ':']).next())
217 {
218 exports.push(name.to_string());
219 }
220 }
221
222 DepInfo {
223 imports: imports.into_iter().collect(),
224 exports,
225 }
226}
227
228fn extract_go_deps(content: &str) -> DepInfo {
229 let mut imports = BTreeSet::new();
230 let mut exports = Vec::new();
231
232 let mut in_import_block = false;
233 for line in content.lines() {
234 let trimmed = line.trim();
235
236 if trimmed.starts_with("import (") {
237 in_import_block = true;
238 continue;
239 }
240 if in_import_block {
241 if trimmed == ")" {
242 in_import_block = false;
243 continue;
244 }
245 if let Some(caps) = go_import_re().captures(trimmed) {
246 imports.insert(caps[1].to_string());
247 }
248 }
249
250 if trimmed.starts_with("func ") {
251 let name_part = trimmed.strip_prefix("func ").unwrap_or("");
252 if let Some(name) = name_part.split('(').next() {
253 let name = name.trim();
254 if !name.is_empty() && name.starts_with(char::is_uppercase) {
255 exports.push(name.to_string());
256 }
257 }
258 }
259 }
260
261 DepInfo {
262 imports: imports.into_iter().collect(),
263 exports,
264 }
265}
266
267#[cfg(feature = "tree-sitter")]
268fn extract_kotlin_deps(content: &str) -> DepInfo {
269 let analysis = deep_queries::analyze(content, "kt");
270 let imports = analysis
271 .imports
272 .into_iter()
273 .map(|import| match import.kind {
274 ImportKind::Star => format!("{}.*", import.source),
275 _ => import.source,
276 })
277 .collect();
278
279 DepInfo {
280 imports,
281 exports: analysis.exports,
282 }
283}
284
285#[cfg(not(feature = "tree-sitter"))]
286fn extract_kotlin_deps(_content: &str) -> DepInfo {
287 DepInfo {
288 imports: Vec::new(),
289 exports: Vec::new(),
290 }
291}
292
293fn clean_import_path(path: &str) -> String {
294 path.trim_start_matches("./")
295 .trim_end_matches(".js")
296 .trim_end_matches(".ts")
297 .trim_end_matches(".tsx")
298 .trim_end_matches(".jsx")
299 .to_string()
300}
301
302fn clean_path_like(path: &str) -> String {
303 path.trim()
304 .trim_start_matches("./")
305 .trim_end_matches(".js")
306 .trim_end_matches(".ts")
307 .trim_end_matches(".tsx")
308 .trim_end_matches(".jsx")
309 .trim_end_matches(".py")
310 .trim_end_matches(".go")
311 .trim_end_matches(".rs")
312 .trim_end_matches(".c")
313 .trim_end_matches(".cpp")
314 .trim_end_matches(".h")
315 .trim_end_matches(".hpp")
316 .trim_end_matches(".php")
317 .trim_end_matches(".dart")
318 .trim_end_matches(".zig")
319 .trim_end_matches(".sh")
320 .trim_end_matches(".bash")
321 .to_string()
322}
323
324fn extract_c_like_deps(content: &str) -> DepInfo {
325 let mut imports = BTreeSet::new();
326 let re = static_regex!(r#"^\s*#\s*include\s*[<"]([^">]+)[">]"#);
327 for line in content.lines() {
328 let trimmed = line.trim();
329 if let Some(caps) = re.captures(trimmed) {
330 let inc = caps[1].trim();
331 if inc.starts_with('.') || inc.contains('/') {
332 imports.insert(clean_path_like(inc));
333 }
334 }
335 }
336 DepInfo {
337 imports: imports.into_iter().collect(),
338 exports: Vec::new(),
339 }
340}
341
342fn extract_ruby_deps(content: &str) -> DepInfo {
343 let mut imports = BTreeSet::new();
344 let re = static_regex!(r#"^\s*require(?:_relative)?\s+['"]([^'"]+)['"]"#);
345 for line in content.lines() {
346 let trimmed = line.trim();
347 if let Some(caps) = re.captures(trimmed) {
348 let req = caps[1].trim();
349 if req.starts_with('.') || req.contains('/') {
350 imports.insert(clean_path_like(req));
351 }
352 }
353 }
354 DepInfo {
355 imports: imports.into_iter().collect(),
356 exports: Vec::new(),
357 }
358}
359
360fn extract_php_deps(content: &str) -> DepInfo {
361 let mut imports = BTreeSet::new();
362 let re = static_regex!(
363 r#"\b(?:require|require_once|include|include_once)\s*\(?\s*['"]([^'"]+)['"]"#
364 );
365 for line in content.lines() {
366 let trimmed = line.trim();
367 if let Some(caps) = re.captures(trimmed) {
368 let p = caps[1].trim();
369 if p.starts_with('.') || p.starts_with('/') {
370 imports.insert(clean_path_like(p));
371 }
372 }
373 }
374 DepInfo {
375 imports: imports.into_iter().collect(),
376 exports: Vec::new(),
377 }
378}
379
380fn extract_bash_deps(content: &str) -> DepInfo {
381 let mut imports = BTreeSet::new();
382 let re = static_regex!(r#"^\s*(?:source|\.)\s+['"]?([^'"\s;]+)['"]?"#);
383 for line in content.lines() {
384 let trimmed = line.trim();
385 if let Some(caps) = re.captures(trimmed) {
386 let p = caps[1].trim();
387 if p.starts_with('.') || p.starts_with('/') {
388 imports.insert(clean_path_like(p));
389 }
390 }
391 }
392 DepInfo {
393 imports: imports.into_iter().collect(),
394 exports: Vec::new(),
395 }
396}
397
398fn extract_export_name(line: &str) -> Option<String> {
399 let without_export = line.strip_prefix("export ")?;
400 let without_default = without_export
401 .strip_prefix("default ")
402 .unwrap_or(without_export);
403
404 for keyword in &[
405 "function ",
406 "async function ",
407 "class ",
408 "const ",
409 "let ",
410 "type ",
411 "interface ",
412 "enum ",
413 ] {
414 if let Some(rest) = without_default.strip_prefix(keyword) {
415 let name = rest
416 .split(|c: char| !c.is_alphanumeric() && c != '_')
417 .next()?;
418 if !name.is_empty() {
419 return Some(name.to_string());
420 }
421 }
422 }
423
424 None
425}
426
427#[cfg(test)]
428mod tests {
429 use super::*;
430
431 #[test]
433 fn rust_exports_strip_generics_and_lifetimes() {
434 let src = "pub struct AutoModeContext<'a> {\n\
435 pub enum Bar<T: Clone> { A(T) }\n\
436 pub trait Baz<T>: Sized {}\n\
437 pub struct Unit;\n\
438 pub struct Tuple(u8);\n\
439 pub fn generic<T: Into<String>>(t: T) {}\n\
440 pub async fn run(x: u8) {}\n\
441 pub fn r#type() {}\n";
442 let deps = extract_deps(src, "rs");
443 assert_eq!(
444 deps.exports,
445 [
446 "AutoModeContext",
447 "Bar",
448 "Baz",
449 "Unit",
450 "Tuple",
451 "generic",
452 "run",
453 "r#type"
454 ]
455 );
456 }
457
458 #[test]
459 fn leading_identifier_edge_cases() {
460 assert_eq!(leading_identifier("Foo<'a>"), "Foo");
461 assert_eq!(leading_identifier("r#match("), "r#match");
462 assert_eq!(leading_identifier("<T>"), "");
463 assert_eq!(leading_identifier("r#"), "");
464 assert_eq!(leading_identifier("naïve_ß()"), "naïve_ß");
465 }
466
467 #[test]
468 fn c_include_relative_is_extracted() {
469 let src = r#"#include "foo/bar.h"
470#include <stdio.h>
471"#;
472 let deps = extract_deps(src, "c");
473 assert!(deps.imports.contains(&"foo/bar".to_string()));
474 assert!(
475 !deps.imports.iter().any(|i| i.contains("stdio")),
476 "system includes should not be treated as internal deps"
477 );
478 }
479
480 #[test]
481 fn ruby_require_relative_is_extracted() {
482 let src = r#"require_relative "./lib/utils"
483require "json"
484"#;
485 let deps = extract_deps(src, "rb");
486 assert!(deps.imports.contains(&"lib/utils".to_string()));
487 assert!(
488 !deps.imports.iter().any(|i| i == "json"),
489 "external requires should not be treated as internal deps"
490 );
491 }
492
493 #[test]
494 fn php_require_is_extracted() {
495 let src = r#"<?php
496require_once "./vendor/autoload.php";
497include "http://example.com/a.php";
498"#;
499 let deps = extract_deps(src, "php");
500 assert!(deps.imports.contains(&"vendor/autoload".to_string()));
501 assert!(
502 deps.imports.iter().all(|i| !i.starts_with("http")),
503 "remote includes should not be treated as internal deps"
504 );
505 }
506
507 #[test]
508 fn bash_source_is_extracted() {
509 let src = r#"#!/usr/bin/env bash
510source "./scripts/env.sh"
511. ../common.sh
512"#;
513 let deps = extract_deps(src, "sh");
514 assert!(deps.imports.contains(&"scripts/env".to_string()));
515 assert!(deps.imports.contains(&"../common".to_string()));
516 }
517
518 #[test]
519 fn dart_import_relative_is_extracted() {
520 let src = r#"import "./src/util.dart";
521import "package:foo/bar.dart";
522"#;
523 let deps = extract_deps(src, "dart");
524 assert!(deps.imports.contains(&"src/util".to_string()));
525 assert!(
526 deps.imports.iter().all(|i| !i.starts_with("package:")),
527 "package imports should not be treated as internal deps"
528 );
529 }
530
531 #[test]
532 fn zig_import_is_extracted() {
533 let src = r#"const m = @import("lib/math.zig");
534const std = @import("std");
535"#;
536 let deps = extract_deps(src, "zig");
537 assert!(deps.imports.contains(&"lib/math".to_string()));
538 assert!(!deps.imports.iter().any(|i| i == "std"), "std is external");
539 }
540
541 #[test]
542 fn imports_are_byte_stable_across_processes() {
543 let cases = [
546 (
547 "use crate::z::Z;\nuse super::m;\nuse crate::a::A;\nuse crate::a::A;\nuse anyhow::Result;\n",
548 "rs",
549 ),
550 (
551 "import x from './zeta';\nimport {y} from './alpha';\nconst m = require('./mid');\n",
552 "ts",
553 ),
554 ("import zlib\nfrom beta import b\nimport alpha\n", "py"),
555 ("import (\n\t\"z/pkg\"\n\t\"a/pkg\"\n)\n", "go"),
556 ("#include \"zed/z.h\"\n#include \"alpha/a.h\"\n", "c"),
557 ("require_relative './z'\nrequire_relative './a'\n", "rb"),
558 ("<?php\nrequire './z.php';\nrequire './a.php';\n", "php"),
559 ("source ./z.sh\nsource ./a.sh\n", "sh"),
560 ];
561 for (src, ext) in cases {
562 let imports = extract_deps(src, ext).imports;
563 let mut sorted = imports.clone();
564 sorted.sort();
565 sorted.dedup();
566 assert_eq!(imports, sorted, "{ext}: imports must be sorted and unique");
567 assert!(
568 imports.len() >= 2,
569 "{ext}: fixture should yield several imports"
570 );
571 }
572 }
573
574 #[test]
575 fn kotlin_deps_are_extracted_from_ast() {
576 let content = r"
577package com.example.app
578
579import com.example.services.UserService
580import com.example.shared.*
581
582class Feature
583fun build(): Feature = Feature()
584";
585 let deps = extract_deps(content, "kt");
586 assert!(
587 deps.imports
588 .contains(&"com.example.services.UserService".to_string())
589 );
590 assert!(deps.imports.contains(&"com.example.shared.*".to_string()));
591 assert!(deps.exports.contains(&"Feature".to_string()));
592 assert!(deps.exports.contains(&"build".to_string()));
593 }
594}