Skip to main content

lean_ctx/core/
deps.rs

1use regex::Regex;
2use std::collections::HashSet;
3use std::sync::OnceLock;
4
5#[cfg(feature = "tree-sitter")]
6use super::deep_queries::{self, ImportKind};
7
8static IMPORT_RE: OnceLock<Regex> = OnceLock::new();
9static REQUIRE_RE: OnceLock<Regex> = OnceLock::new();
10static RUST_USE_RE: OnceLock<Regex> = OnceLock::new();
11static PY_IMPORT_RE: OnceLock<Regex> = OnceLock::new();
12static GO_IMPORT_RE: OnceLock<Regex> = OnceLock::new();
13static C_INCLUDE_RE: OnceLock<Regex> = OnceLock::new();
14static RUBY_REQUIRE_RE: OnceLock<Regex> = OnceLock::new();
15static PHP_INCLUDE_RE: OnceLock<Regex> = OnceLock::new();
16static BASH_SOURCE_RE: OnceLock<Regex> = OnceLock::new();
17static DART_IMPORT_RE: OnceLock<Regex> = OnceLock::new();
18static ZIG_IMPORT_RE: OnceLock<Regex> = OnceLock::new();
19
20fn import_re() -> &'static Regex {
21    IMPORT_RE.get_or_init(|| {
22        Regex::new(r#"import\s+(?:\{[^}]*\}\s+from\s+|.*from\s+)['"]([^'"]+)['"]"#).unwrap()
23    })
24}
25fn require_re() -> &'static Regex {
26    REQUIRE_RE.get_or_init(|| Regex::new(r#"require\(['"]([^'"]+)['"]\)"#).unwrap())
27}
28fn rust_use_re() -> &'static Regex {
29    RUST_USE_RE.get_or_init(|| Regex::new(r"^use\s+([\w:]+)").unwrap())
30}
31fn py_import_re() -> &'static Regex {
32    PY_IMPORT_RE.get_or_init(|| Regex::new(r"^(?:from\s+(\S+)\s+import|import\s+(\S+))").unwrap())
33}
34fn go_import_re() -> &'static Regex {
35    GO_IMPORT_RE.get_or_init(|| Regex::new(r#""([^"]+)""#).unwrap())
36}
37
38#[derive(Debug, Clone)]
39pub struct DepInfo {
40    pub imports: Vec<String>,
41    pub exports: Vec<String>,
42}
43
44pub fn extract_deps(content: &str, ext: &str) -> DepInfo {
45    let lang = crate::core::language_capabilities::language_for_ext(ext);
46    match lang {
47        Some(crate::core::language_capabilities::LanguageId::TypeScript)
48        | Some(crate::core::language_capabilities::LanguageId::JavaScript)
49        | Some(crate::core::language_capabilities::LanguageId::Vue)
50        | Some(crate::core::language_capabilities::LanguageId::Svelte) => extract_ts_deps(content),
51        Some(crate::core::language_capabilities::LanguageId::Rust) => extract_rust_deps(content),
52        Some(crate::core::language_capabilities::LanguageId::Python) => {
53            extract_python_deps(content)
54        }
55        Some(crate::core::language_capabilities::LanguageId::Go) => extract_go_deps(content),
56        Some(crate::core::language_capabilities::LanguageId::C)
57        | Some(crate::core::language_capabilities::LanguageId::Cpp) => extract_c_like_deps(content),
58        Some(crate::core::language_capabilities::LanguageId::Ruby) => extract_ruby_deps(content),
59        Some(crate::core::language_capabilities::LanguageId::Php) => extract_php_deps(content),
60        Some(crate::core::language_capabilities::LanguageId::Bash) => extract_bash_deps(content),
61        Some(crate::core::language_capabilities::LanguageId::Kotlin) => {
62            extract_kotlin_deps(content)
63        }
64        Some(crate::core::language_capabilities::LanguageId::Dart) => {
65            let mut imports = HashSet::new();
66            let re = DART_IMPORT_RE.get_or_init(|| {
67                Regex::new(r#"^\s*(?:import|export|part)\s+['"]([^'"]+)['"]"#).unwrap()
68            });
69            for line in content.lines() {
70                let trimmed = line.trim();
71                if let Some(caps) = re.captures(trimmed) {
72                    let p = caps[1].trim();
73                    if p.starts_with('.') || p.starts_with('/') {
74                        imports.insert(clean_path_like(p));
75                    }
76                }
77            }
78            DepInfo {
79                imports: imports.into_iter().collect(),
80                exports: Vec::new(),
81            }
82        }
83        Some(crate::core::language_capabilities::LanguageId::Zig) => {
84            let mut imports = HashSet::new();
85            let re =
86                ZIG_IMPORT_RE.get_or_init(|| Regex::new(r#"@import\(\s*"([^"]+)"\s*\)"#).unwrap());
87            for line in content.lines() {
88                let trimmed = line.trim();
89                if let Some(caps) = re.captures(trimmed) {
90                    let p = caps[1].trim();
91                    if p.starts_with('.') || p.contains('/') || p.ends_with(".zig") {
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            if let Some(name) = extract_export_name(trimmed) {
130                exports.push(name);
131            }
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        {
167            if let Some(name) = trimmed.split_whitespace().nth(2) {
168                let clean = name.trim_end_matches(|c: char| !c.is_alphanumeric() && c != '_');
169                exports.push(clean.to_string());
170            }
171        }
172    }
173
174    DepInfo {
175        imports: imports.into_iter().collect(),
176        exports,
177    }
178}
179
180fn extract_python_deps(content: &str) -> DepInfo {
181    let mut imports = HashSet::new();
182    let mut exports = Vec::new();
183
184    for line in content.lines() {
185        let trimmed = line.trim();
186
187        if let Some(caps) = py_import_re().captures(trimmed) {
188            if let Some(m) = caps.get(1).or(caps.get(2)) {
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
199        if trimmed.starts_with("def ") && !trimmed.contains("_") {
200            if let Some(name) = trimmed
201                .strip_prefix("def ")
202                .and_then(|s| s.split('(').next())
203            {
204                exports.push(name.to_string());
205            }
206        } else if trimmed.starts_with("class ") {
207            if let Some(name) = trimmed
208                .strip_prefix("class ")
209                .and_then(|s| s.split(['(', ':']).next())
210            {
211                exports.push(name.to_string());
212            }
213        }
214    }
215
216    DepInfo {
217        imports: imports.into_iter().collect(),
218        exports,
219    }
220}
221
222fn extract_go_deps(content: &str) -> DepInfo {
223    let mut imports = HashSet::new();
224    let mut exports = Vec::new();
225
226    let mut in_import_block = false;
227    for line in content.lines() {
228        let trimmed = line.trim();
229
230        if trimmed.starts_with("import (") {
231            in_import_block = true;
232            continue;
233        }
234        if in_import_block {
235            if trimmed == ")" {
236                in_import_block = false;
237                continue;
238            }
239            if let Some(caps) = go_import_re().captures(trimmed) {
240                imports.insert(caps[1].to_string());
241            }
242        }
243
244        if trimmed.starts_with("func ") {
245            let name_part = trimmed.strip_prefix("func ").unwrap_or("");
246            if let Some(name) = name_part.split('(').next() {
247                let name = name.trim();
248                if !name.is_empty() && name.starts_with(char::is_uppercase) {
249                    exports.push(name.to_string());
250                }
251            }
252        }
253    }
254
255    DepInfo {
256        imports: imports.into_iter().collect(),
257        exports,
258    }
259}
260
261#[cfg(feature = "tree-sitter")]
262fn extract_kotlin_deps(content: &str) -> DepInfo {
263    let analysis = deep_queries::analyze(content, "kt");
264    let imports = analysis
265        .imports
266        .into_iter()
267        .map(|import| match import.kind {
268            ImportKind::Star => format!("{}.*", import.source),
269            _ => import.source,
270        })
271        .collect();
272
273    DepInfo {
274        imports,
275        exports: analysis.exports,
276    }
277}
278
279#[cfg(not(feature = "tree-sitter"))]
280fn extract_kotlin_deps(_content: &str) -> DepInfo {
281    DepInfo {
282        imports: Vec::new(),
283        exports: Vec::new(),
284    }
285}
286
287fn clean_import_path(path: &str) -> String {
288    path.trim_start_matches("./")
289        .trim_end_matches(".js")
290        .trim_end_matches(".ts")
291        .trim_end_matches(".tsx")
292        .trim_end_matches(".jsx")
293        .to_string()
294}
295
296fn clean_path_like(path: &str) -> String {
297    path.trim()
298        .trim_start_matches("./")
299        .trim_end_matches(".js")
300        .trim_end_matches(".ts")
301        .trim_end_matches(".tsx")
302        .trim_end_matches(".jsx")
303        .trim_end_matches(".py")
304        .trim_end_matches(".go")
305        .trim_end_matches(".rs")
306        .trim_end_matches(".c")
307        .trim_end_matches(".cpp")
308        .trim_end_matches(".h")
309        .trim_end_matches(".hpp")
310        .trim_end_matches(".php")
311        .trim_end_matches(".dart")
312        .trim_end_matches(".zig")
313        .trim_end_matches(".sh")
314        .trim_end_matches(".bash")
315        .to_string()
316}
317
318fn extract_c_like_deps(content: &str) -> DepInfo {
319    let mut imports = HashSet::new();
320    let re =
321        C_INCLUDE_RE.get_or_init(|| Regex::new(r#"^\s*#\s*include\s*[<"]([^">]+)[">]"#).unwrap());
322    for line in content.lines() {
323        let trimmed = line.trim();
324        if let Some(caps) = re.captures(trimmed) {
325            let inc = caps[1].trim();
326            if inc.starts_with('.') || inc.contains('/') {
327                imports.insert(clean_path_like(inc));
328            }
329        }
330    }
331    DepInfo {
332        imports: imports.into_iter().collect(),
333        exports: Vec::new(),
334    }
335}
336
337fn extract_ruby_deps(content: &str) -> DepInfo {
338    let mut imports = HashSet::new();
339    let re = RUBY_REQUIRE_RE
340        .get_or_init(|| Regex::new(r#"^\s*require(?:_relative)?\s+['"]([^'"]+)['"]"#).unwrap());
341    for line in content.lines() {
342        let trimmed = line.trim();
343        if let Some(caps) = re.captures(trimmed) {
344            let req = caps[1].trim();
345            if req.starts_with('.') || req.contains('/') {
346                imports.insert(clean_path_like(req));
347            }
348        }
349    }
350    DepInfo {
351        imports: imports.into_iter().collect(),
352        exports: Vec::new(),
353    }
354}
355
356fn extract_php_deps(content: &str) -> DepInfo {
357    let mut imports = HashSet::new();
358    let re = PHP_INCLUDE_RE.get_or_init(|| {
359        Regex::new(r#"\b(?:require|require_once|include|include_once)\s*\(?\s*['"]([^'"]+)['"]"#)
360            .unwrap()
361    });
362    for line in content.lines() {
363        let trimmed = line.trim();
364        if let Some(caps) = re.captures(trimmed) {
365            let p = caps[1].trim();
366            if p.starts_with('.') || p.starts_with('/') {
367                imports.insert(clean_path_like(p));
368            }
369        }
370    }
371    DepInfo {
372        imports: imports.into_iter().collect(),
373        exports: Vec::new(),
374    }
375}
376
377fn extract_bash_deps(content: &str) -> DepInfo {
378    let mut imports = HashSet::new();
379    let re = BASH_SOURCE_RE
380        .get_or_init(|| Regex::new(r#"^\s*(?:source|\.)\s+['"]?([^'"\s;]+)['"]?"#).unwrap());
381    for line in content.lines() {
382        let trimmed = line.trim();
383        if let Some(caps) = re.captures(trimmed) {
384            let p = caps[1].trim();
385            if p.starts_with('.') || p.starts_with('/') {
386                imports.insert(clean_path_like(p));
387            }
388        }
389    }
390    DepInfo {
391        imports: imports.into_iter().collect(),
392        exports: Vec::new(),
393    }
394}
395
396fn extract_export_name(line: &str) -> Option<String> {
397    let without_export = line.strip_prefix("export ")?;
398    let without_default = without_export
399        .strip_prefix("default ")
400        .unwrap_or(without_export);
401
402    for keyword in &[
403        "function ",
404        "async function ",
405        "class ",
406        "const ",
407        "let ",
408        "type ",
409        "interface ",
410        "enum ",
411    ] {
412        if let Some(rest) = without_default.strip_prefix(keyword) {
413            let name = rest
414                .split(|c: char| !c.is_alphanumeric() && c != '_')
415                .next()?;
416            if !name.is_empty() {
417                return Some(name.to_string());
418            }
419        }
420    }
421
422    None
423}
424
425#[cfg(test)]
426mod tests {
427    use super::*;
428
429    #[test]
430    fn c_include_relative_is_extracted() {
431        let src = r#"#include "foo/bar.h"
432#include <stdio.h>
433"#;
434        let deps = extract_deps(src, "c");
435        assert!(deps.imports.contains(&"foo/bar".to_string()));
436        assert!(
437            !deps.imports.iter().any(|i| i.contains("stdio")),
438            "system includes should not be treated as internal deps"
439        );
440    }
441
442    #[test]
443    fn ruby_require_relative_is_extracted() {
444        let src = r#"require_relative "./lib/utils"
445require "json"
446"#;
447        let deps = extract_deps(src, "rb");
448        assert!(deps.imports.contains(&"lib/utils".to_string()));
449        assert!(
450            !deps.imports.iter().any(|i| i == "json"),
451            "external requires should not be treated as internal deps"
452        );
453    }
454
455    #[test]
456    fn php_require_is_extracted() {
457        let src = r#"<?php
458require_once "./vendor/autoload.php";
459include "http://example.com/a.php";
460"#;
461        let deps = extract_deps(src, "php");
462        assert!(deps.imports.contains(&"vendor/autoload".to_string()));
463        assert!(
464            deps.imports.iter().all(|i| !i.starts_with("http")),
465            "remote includes should not be treated as internal deps"
466        );
467    }
468
469    #[test]
470    fn bash_source_is_extracted() {
471        let src = r#"#!/usr/bin/env bash
472source "./scripts/env.sh"
473. ../common.sh
474"#;
475        let deps = extract_deps(src, "sh");
476        assert!(deps.imports.contains(&"scripts/env".to_string()));
477        assert!(deps.imports.contains(&"../common".to_string()));
478    }
479
480    #[test]
481    fn dart_import_relative_is_extracted() {
482        let src = r#"import "./src/util.dart";
483import "package:foo/bar.dart";
484"#;
485        let deps = extract_deps(src, "dart");
486        assert!(deps.imports.contains(&"src/util".to_string()));
487        assert!(
488            deps.imports.iter().all(|i| !i.starts_with("package:")),
489            "package imports should not be treated as internal deps"
490        );
491    }
492
493    #[test]
494    fn zig_import_is_extracted() {
495        let src = r#"const m = @import("lib/math.zig");
496const std = @import("std");
497"#;
498        let deps = extract_deps(src, "zig");
499        assert!(deps.imports.contains(&"lib/math".to_string()));
500        assert!(!deps.imports.iter().any(|i| i == "std"), "std is external");
501    }
502
503    #[test]
504    fn kotlin_deps_are_extracted_from_ast() {
505        let content = r#"
506package com.example.app
507
508import com.example.services.UserService
509import com.example.shared.*
510
511class Feature
512fun build(): Feature = Feature()
513"#;
514        let deps = extract_deps(content, "kt");
515        assert!(deps
516            .imports
517            .contains(&"com.example.services.UserService".to_string()));
518        assert!(deps.imports.contains(&"com.example.shared.*".to_string()));
519        assert!(deps.exports.contains(&"Feature".to_string()));
520        assert!(deps.exports.contains(&"build".to_string()));
521    }
522}