Skip to main content

lean_ctx/core/
deps.rs

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        // #1911: cut at the first non-identifier char, so generics and
156        // lifetimes (`Foo<'a>`, `bar<T: X>(`) never leak into the export name.
157        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
177/// The identifier `s` starts with (raw `r#ident` kept whole), up to the first
178/// char that cannot continue it — `<`, `(`, `:`, `{`, whitespace, ….
179fn 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    /// #1911: generics and lifetimes are cut off the export name.
432    #[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        // #1891: a randomly seeded HashSet made the `deps` line of map/signatures
544        // differ per process. The order must be a pure function of the content.
545        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}