Skip to main content

lean_ctx/core/
deps.rs

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}