1use regex::Regex;
2use std::collections::HashSet;
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 = HashSet::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 = HashSet::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 = HashSet::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 = HashSet::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 if trimmed.starts_with("pub fn ") || trimmed.starts_with("pub async fn ") {
156 if let Some(name) = trimmed
157 .split('(')
158 .next()
159 .and_then(|s| s.split_whitespace().last())
160 {
161 exports.push(name.to_string());
162 }
163 } else if (trimmed.starts_with("pub struct ")
164 || trimmed.starts_with("pub enum ")
165 || trimmed.starts_with("pub trait "))
166 && let Some(name) = trimmed.split_whitespace().nth(2)
167 {
168 let clean = name.trim_end_matches(|c: char| !c.is_alphanumeric() && c != '_');
169 exports.push(clean.to_string());
170 }
171 }
172
173 DepInfo {
174 imports: imports.into_iter().collect(),
175 exports,
176 }
177}
178
179fn extract_python_deps(content: &str) -> DepInfo {
180 let mut imports = HashSet::new();
181 let mut exports = Vec::new();
182
183 for line in content.lines() {
184 let trimmed = line.trim();
185
186 if let Some(caps) = py_import_re().captures(trimmed)
187 && let Some(m) = caps.get(1).or(caps.get(2))
188 {
189 let module = m.as_str();
190 if !module.starts_with("os")
191 && !module.starts_with("sys")
192 && !module.starts_with("json")
193 {
194 imports.insert(module.to_string());
195 }
196 }
197
198 if trimmed.starts_with("def ") && !trimmed.contains('_') {
199 if let Some(name) = trimmed
200 .strip_prefix("def ")
201 .and_then(|s| s.split('(').next())
202 {
203 exports.push(name.to_string());
204 }
205 } else if trimmed.starts_with("class ")
206 && let Some(name) = trimmed
207 .strip_prefix("class ")
208 .and_then(|s| s.split(['(', ':']).next())
209 {
210 exports.push(name.to_string());
211 }
212 }
213
214 DepInfo {
215 imports: imports.into_iter().collect(),
216 exports,
217 }
218}
219
220fn extract_go_deps(content: &str) -> DepInfo {
221 let mut imports = HashSet::new();
222 let mut exports = Vec::new();
223
224 let mut in_import_block = false;
225 for line in content.lines() {
226 let trimmed = line.trim();
227
228 if trimmed.starts_with("import (") {
229 in_import_block = true;
230 continue;
231 }
232 if in_import_block {
233 if trimmed == ")" {
234 in_import_block = false;
235 continue;
236 }
237 if let Some(caps) = go_import_re().captures(trimmed) {
238 imports.insert(caps[1].to_string());
239 }
240 }
241
242 if trimmed.starts_with("func ") {
243 let name_part = trimmed.strip_prefix("func ").unwrap_or("");
244 if let Some(name) = name_part.split('(').next() {
245 let name = name.trim();
246 if !name.is_empty() && name.starts_with(char::is_uppercase) {
247 exports.push(name.to_string());
248 }
249 }
250 }
251 }
252
253 DepInfo {
254 imports: imports.into_iter().collect(),
255 exports,
256 }
257}
258
259#[cfg(feature = "tree-sitter")]
260fn extract_kotlin_deps(content: &str) -> DepInfo {
261 let analysis = deep_queries::analyze(content, "kt");
262 let imports = analysis
263 .imports
264 .into_iter()
265 .map(|import| match import.kind {
266 ImportKind::Star => format!("{}.*", import.source),
267 _ => import.source,
268 })
269 .collect();
270
271 DepInfo {
272 imports,
273 exports: analysis.exports,
274 }
275}
276
277#[cfg(not(feature = "tree-sitter"))]
278fn extract_kotlin_deps(_content: &str) -> DepInfo {
279 DepInfo {
280 imports: Vec::new(),
281 exports: Vec::new(),
282 }
283}
284
285fn clean_import_path(path: &str) -> String {
286 path.trim_start_matches("./")
287 .trim_end_matches(".js")
288 .trim_end_matches(".ts")
289 .trim_end_matches(".tsx")
290 .trim_end_matches(".jsx")
291 .to_string()
292}
293
294fn clean_path_like(path: &str) -> String {
295 path.trim()
296 .trim_start_matches("./")
297 .trim_end_matches(".js")
298 .trim_end_matches(".ts")
299 .trim_end_matches(".tsx")
300 .trim_end_matches(".jsx")
301 .trim_end_matches(".py")
302 .trim_end_matches(".go")
303 .trim_end_matches(".rs")
304 .trim_end_matches(".c")
305 .trim_end_matches(".cpp")
306 .trim_end_matches(".h")
307 .trim_end_matches(".hpp")
308 .trim_end_matches(".php")
309 .trim_end_matches(".dart")
310 .trim_end_matches(".zig")
311 .trim_end_matches(".sh")
312 .trim_end_matches(".bash")
313 .to_string()
314}
315
316fn extract_c_like_deps(content: &str) -> DepInfo {
317 let mut imports = HashSet::new();
318 let re = static_regex!(r#"^\s*#\s*include\s*[<"]([^">]+)[">]"#);
319 for line in content.lines() {
320 let trimmed = line.trim();
321 if let Some(caps) = re.captures(trimmed) {
322 let inc = caps[1].trim();
323 if inc.starts_with('.') || inc.contains('/') {
324 imports.insert(clean_path_like(inc));
325 }
326 }
327 }
328 DepInfo {
329 imports: imports.into_iter().collect(),
330 exports: Vec::new(),
331 }
332}
333
334fn extract_ruby_deps(content: &str) -> DepInfo {
335 let mut imports = HashSet::new();
336 let re = static_regex!(r#"^\s*require(?:_relative)?\s+['"]([^'"]+)['"]"#);
337 for line in content.lines() {
338 let trimmed = line.trim();
339 if let Some(caps) = re.captures(trimmed) {
340 let req = caps[1].trim();
341 if req.starts_with('.') || req.contains('/') {
342 imports.insert(clean_path_like(req));
343 }
344 }
345 }
346 DepInfo {
347 imports: imports.into_iter().collect(),
348 exports: Vec::new(),
349 }
350}
351
352fn extract_php_deps(content: &str) -> DepInfo {
353 let mut imports = HashSet::new();
354 let re = static_regex!(
355 r#"\b(?:require|require_once|include|include_once)\s*\(?\s*['"]([^'"]+)['"]"#
356 );
357 for line in content.lines() {
358 let trimmed = line.trim();
359 if let Some(caps) = re.captures(trimmed) {
360 let p = caps[1].trim();
361 if p.starts_with('.') || p.starts_with('/') {
362 imports.insert(clean_path_like(p));
363 }
364 }
365 }
366 DepInfo {
367 imports: imports.into_iter().collect(),
368 exports: Vec::new(),
369 }
370}
371
372fn extract_bash_deps(content: &str) -> DepInfo {
373 let mut imports = HashSet::new();
374 let re = static_regex!(r#"^\s*(?:source|\.)\s+['"]?([^'"\s;]+)['"]?"#);
375 for line in content.lines() {
376 let trimmed = line.trim();
377 if let Some(caps) = re.captures(trimmed) {
378 let p = caps[1].trim();
379 if p.starts_with('.') || p.starts_with('/') {
380 imports.insert(clean_path_like(p));
381 }
382 }
383 }
384 DepInfo {
385 imports: imports.into_iter().collect(),
386 exports: Vec::new(),
387 }
388}
389
390fn extract_export_name(line: &str) -> Option<String> {
391 let without_export = line.strip_prefix("export ")?;
392 let without_default = without_export
393 .strip_prefix("default ")
394 .unwrap_or(without_export);
395
396 for keyword in &[
397 "function ",
398 "async function ",
399 "class ",
400 "const ",
401 "let ",
402 "type ",
403 "interface ",
404 "enum ",
405 ] {
406 if let Some(rest) = without_default.strip_prefix(keyword) {
407 let name = rest
408 .split(|c: char| !c.is_alphanumeric() && c != '_')
409 .next()?;
410 if !name.is_empty() {
411 return Some(name.to_string());
412 }
413 }
414 }
415
416 None
417}
418
419#[cfg(test)]
420mod tests {
421 use super::*;
422
423 #[test]
424 fn c_include_relative_is_extracted() {
425 let src = r#"#include "foo/bar.h"
426#include <stdio.h>
427"#;
428 let deps = extract_deps(src, "c");
429 assert!(deps.imports.contains(&"foo/bar".to_string()));
430 assert!(
431 !deps.imports.iter().any(|i| i.contains("stdio")),
432 "system includes should not be treated as internal deps"
433 );
434 }
435
436 #[test]
437 fn ruby_require_relative_is_extracted() {
438 let src = r#"require_relative "./lib/utils"
439require "json"
440"#;
441 let deps = extract_deps(src, "rb");
442 assert!(deps.imports.contains(&"lib/utils".to_string()));
443 assert!(
444 !deps.imports.iter().any(|i| i == "json"),
445 "external requires should not be treated as internal deps"
446 );
447 }
448
449 #[test]
450 fn php_require_is_extracted() {
451 let src = r#"<?php
452require_once "./vendor/autoload.php";
453include "http://example.com/a.php";
454"#;
455 let deps = extract_deps(src, "php");
456 assert!(deps.imports.contains(&"vendor/autoload".to_string()));
457 assert!(
458 deps.imports.iter().all(|i| !i.starts_with("http")),
459 "remote includes should not be treated as internal deps"
460 );
461 }
462
463 #[test]
464 fn bash_source_is_extracted() {
465 let src = r#"#!/usr/bin/env bash
466source "./scripts/env.sh"
467. ../common.sh
468"#;
469 let deps = extract_deps(src, "sh");
470 assert!(deps.imports.contains(&"scripts/env".to_string()));
471 assert!(deps.imports.contains(&"../common".to_string()));
472 }
473
474 #[test]
475 fn dart_import_relative_is_extracted() {
476 let src = r#"import "./src/util.dart";
477import "package:foo/bar.dart";
478"#;
479 let deps = extract_deps(src, "dart");
480 assert!(deps.imports.contains(&"src/util".to_string()));
481 assert!(
482 deps.imports.iter().all(|i| !i.starts_with("package:")),
483 "package imports should not be treated as internal deps"
484 );
485 }
486
487 #[test]
488 fn zig_import_is_extracted() {
489 let src = r#"const m = @import("lib/math.zig");
490const std = @import("std");
491"#;
492 let deps = extract_deps(src, "zig");
493 assert!(deps.imports.contains(&"lib/math".to_string()));
494 assert!(!deps.imports.iter().any(|i| i == "std"), "std is external");
495 }
496
497 #[test]
498 fn kotlin_deps_are_extracted_from_ast() {
499 let content = r"
500package com.example.app
501
502import com.example.services.UserService
503import com.example.shared.*
504
505class Feature
506fun build(): Feature = Feature()
507";
508 let deps = extract_deps(content, "kt");
509 assert!(
510 deps.imports
511 .contains(&"com.example.services.UserService".to_string())
512 );
513 assert!(deps.imports.contains(&"com.example.shared.*".to_string()));
514 assert!(deps.exports.contains(&"Feature".to_string()));
515 assert!(deps.exports.contains(&"build".to_string()));
516 }
517}