Skip to main content

khive_pack_code/
imports.rs

1//! Regex-based import scan for `code.ingest` L1.5 (ADR-085 Amendment 2 B3).
2//!
3//! Syntax-only, per B2: no type-checking, no compilation. Coverage-floor
4//! extraction — good enough to produce `depends_on` edges and module paths,
5//! not a full parser. Pure functions; no filesystem or storage access beyond
6//! the caller-supplied file path (used only for path arithmetic).
7
8use std::path::{Component, Path};
9
10use regex::Regex;
11use std::sync::OnceLock;
12
13/// File extensions this PR's L1.5 tier scans, keyed by language.
14pub(crate) fn extension_for_language(language: &str) -> Option<&'static str> {
15    match language {
16        "rust" => Some("rs"),
17        "python" => Some("py"),
18        "typescript" => Some("ts"),
19        _ => None,
20    }
21}
22
23fn rust_use_re() -> &'static Regex {
24    static RE: OnceLock<Regex> = OnceLock::new();
25    RE.get_or_init(|| {
26        Regex::new(r"(?m)^\s*(?:pub(?:\([^)]*\))?\s+)?use\s+([A-Za-z_][A-Za-z0-9_:]*)").unwrap()
27    })
28}
29
30fn rust_extern_crate_re() -> &'static Regex {
31    static RE: OnceLock<Regex> = OnceLock::new();
32    RE.get_or_init(|| Regex::new(r"(?m)^\s*extern\s+crate\s+([A-Za-z_][A-Za-z0-9_]*)").unwrap())
33}
34
35fn python_import_re() -> &'static Regex {
36    static RE: OnceLock<Regex> = OnceLock::new();
37    RE.get_or_init(|| Regex::new(r"(?m)^\s*import\s+([A-Za-z_][A-Za-z0-9_.]*)").unwrap())
38}
39
40fn python_from_re() -> &'static Regex {
41    static RE: OnceLock<Regex> = OnceLock::new();
42    RE.get_or_init(|| Regex::new(r"(?m)^\s*from\s+([.]*[A-Za-z0-9_.]*)\s+import").unwrap())
43}
44
45fn ts_import_re() -> &'static Regex {
46    static RE: OnceLock<Regex> = OnceLock::new();
47    RE.get_or_init(|| Regex::new(r#"import(?:[^'";]*?from)?\s*['"]([^'"]+)['"]"#).unwrap())
48}
49
50fn ts_require_re() -> &'static Regex {
51    static RE: OnceLock<Regex> = OnceLock::new();
52    RE.get_or_init(|| Regex::new(r#"require\(\s*['"]([^'"]+)['"]\s*\)"#).unwrap())
53}
54
55/// Extract raw, unclassified import specifiers from `content`.
56pub(crate) fn extract_raw_imports(language: &str, content: &str) -> Vec<String> {
57    let mut out = Vec::new();
58    match language {
59        "rust" => {
60            for c in rust_use_re().captures_iter(content) {
61                out.push(c[1].trim_end_matches(':').to_string());
62            }
63            for c in rust_extern_crate_re().captures_iter(content) {
64                out.push(c[1].to_string());
65            }
66        }
67        "python" => {
68            for c in python_import_re().captures_iter(content) {
69                out.push(c[1].to_string());
70            }
71            for c in python_from_re().captures_iter(content) {
72                out.push(c[1].to_string());
73            }
74        }
75        "typescript" => {
76            for c in ts_import_re().captures_iter(content) {
77                out.push(c[1].to_string());
78            }
79            for c in ts_require_re().captures_iter(content) {
80                out.push(c[1].to_string());
81            }
82        }
83        _ => {}
84    }
85    out
86}
87
88/// Resolution outcome of one raw import specifier against the declaring
89/// file's own module path and project name.
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub(crate) enum Resolved {
92    /// A module in the SAME `source_project` — carries the target's
93    /// `module_path`.
94    IntraModule(String),
95    /// A different (possibly not-yet-ingested) project — carries the
96    /// external project's name (B6 unresolved-specifier candidate).
97    ExternalProject(String),
98    /// A same-module or standard-library reference — not a project edge.
99    Skip,
100}
101
102const RUST_STD_CRATES: &[&str] = &["std", "core", "alloc", "proc_macro", "test"];
103const PY_STDLIB: &[&str] = &[
104    "os",
105    "sys",
106    "re",
107    "json",
108    "typing",
109    "collections",
110    "itertools",
111    "functools",
112    "pathlib",
113    "subprocess",
114    "logging",
115    "unittest",
116    "abc",
117    "dataclasses",
118    "enum",
119    "math",
120    "random",
121    "datetime",
122    "io",
123    "argparse",
124    "asyncio",
125];
126
127fn normalize_crate_name(name: &str) -> String {
128    name.replace('-', "_")
129}
130
131pub(crate) fn classify_import(
132    language: &str,
133    raw: &str,
134    current_module_path: &str,
135    project_name: &str,
136) -> Resolved {
137    match language {
138        "rust" => classify_rust(raw, current_module_path, project_name),
139        "python" => classify_python(raw, current_module_path, project_name),
140        "typescript" => classify_typescript(raw),
141        _ => Resolved::Skip,
142    }
143}
144
145fn classify_rust(raw: &str, current_module_path: &str, project_name: &str) -> Resolved {
146    if let Some(rest) = raw.strip_prefix("crate::") {
147        return module_or_skip(rest);
148    }
149    if raw.starts_with("self::") || raw == "self" {
150        return Resolved::Skip;
151    }
152    if let Some(rest) = raw.strip_prefix("super::") {
153        let mut parent: Vec<&str> = current_module_path.split("::").collect();
154        parent.pop();
155        if parent.is_empty() {
156            return module_or_skip(rest);
157        }
158        return Resolved::IntraModule(format!("{}::{}", parent.join("::"), rest));
159    }
160    let mut segments = raw.splitn(2, "::");
161    let first = segments.next().unwrap_or_default();
162    if first.is_empty() || first == "crate" {
163        return Resolved::Skip;
164    }
165    if RUST_STD_CRATES.contains(&first) {
166        return Resolved::Skip;
167    }
168    if normalize_crate_name(first) == normalize_crate_name(project_name) {
169        return module_or_skip(segments.next().unwrap_or(""));
170    }
171    Resolved::ExternalProject(first.to_string())
172}
173
174fn module_or_skip(module_path: &str) -> Resolved {
175    if module_path.is_empty() {
176        Resolved::Skip
177    } else {
178        Resolved::IntraModule(module_path.to_string())
179    }
180}
181
182fn classify_python(raw: &str, current_module_path: &str, project_name: &str) -> Resolved {
183    if let Some(stripped) = raw.strip_prefix('.') {
184        let mut level = 1usize;
185        let mut rest = stripped;
186        while let Some(s) = rest.strip_prefix('.') {
187            level += 1;
188            rest = s;
189        }
190        let mut base: Vec<&str> = current_module_path.split('.').collect();
191        // The declaring module's own containing package is one level up
192        // from itself; a single leading dot means "this package".
193        base.pop();
194        for _ in 1..level {
195            base.pop();
196        }
197        if rest.is_empty() {
198            return module_or_skip(&base.join("."));
199        }
200        if base.is_empty() {
201            return Resolved::IntraModule(rest.to_string());
202        }
203        return Resolved::IntraModule(format!("{}.{}", base.join("."), rest));
204    }
205    if raw.is_empty() {
206        return Resolved::Skip;
207    }
208    let first = raw.split('.').next().unwrap_or_default();
209    if PY_STDLIB.contains(&first) {
210        return Resolved::Skip;
211    }
212    let normalized_first = first.replace('-', "_");
213    let normalized_project = project_name.replace('-', "_");
214    if normalized_first == normalized_project {
215        return Resolved::IntraModule(raw.to_string());
216    }
217    Resolved::ExternalProject(first.to_string())
218}
219
220fn classify_typescript(raw: &str) -> Resolved {
221    if raw.starts_with('.') {
222        return Resolved::IntraModule(raw.to_string());
223    }
224    if raw.starts_with('@') {
225        let mut parts = raw.splitn(3, '/');
226        let scope = parts.next().unwrap_or_default();
227        let pkg = parts.next().unwrap_or_default();
228        if pkg.is_empty() {
229            return Resolved::Skip;
230        }
231        return Resolved::ExternalProject(format!("{scope}/{pkg}"));
232    }
233    let first = raw.split('/').next().unwrap_or_default();
234    if first.is_empty() {
235        return Resolved::Skip;
236    }
237    Resolved::ExternalProject(first.to_string())
238}
239
240/// Join a TypeScript-style relative specifier (`./foo`, `../bar/baz`)
241/// against the declaring file's own directory (relative to `project_root`),
242/// producing an extension-stripped module path.
243pub(crate) fn resolve_relative_ts_module(current_file_dir_rel: &Path, specifier: &str) -> String {
244    let mut components: Vec<String> = current_file_dir_rel
245        .components()
246        .filter_map(|c| match c {
247            Component::Normal(s) => Some(s.to_string_lossy().to_string()),
248            _ => None,
249        })
250        .collect();
251    for part in specifier.split('/') {
252        match part {
253            "" | "." => {}
254            ".." => {
255                components.pop();
256            }
257            other => components.push(other.to_string()),
258        }
259    }
260    let joined = components.join("/");
261    joined
262        .strip_suffix(".ts")
263        .or_else(|| joined.strip_suffix(".tsx"))
264        .unwrap_or(&joined)
265        .to_string()
266}
267
268/// Compute the language-native canonical `module_path` for a source file,
269/// relative to `project_root` (B4). Returns `None` for a file outside
270/// `project_root`.
271pub(crate) fn module_path_for_file(
272    file: &Path,
273    project_root: &Path,
274    language: &str,
275) -> Option<String> {
276    let rel = file.strip_prefix(project_root).ok()?;
277    let mut components: Vec<String> = rel
278        .components()
279        .filter_map(|c| match c {
280            Component::Normal(s) => Some(s.to_string_lossy().to_string()),
281            _ => None,
282        })
283        .collect();
284    // Rust's `src/` layout convention is lexical, not filesystem-probed
285    // (fixtures may be constructed in-memory, and the walk already knows the
286    // file exists): strip a leading `src` component for Rust only.
287    if language == "rust" && components.first().map(String::as_str) == Some("src") {
288        components.remove(0);
289    }
290    if components.is_empty() {
291        return None;
292    }
293
294    match language {
295        "rust" => {
296            let stem = components.last()?.strip_suffix(".rs")?.to_string();
297            let is_root = components.len() == 1 && (stem == "lib" || stem == "main");
298            *components.last_mut()? = stem.clone();
299            if is_root {
300                return Some("crate".to_string());
301            }
302            if stem == "mod" {
303                components.pop();
304                if components.is_empty() {
305                    return Some("crate".to_string());
306                }
307            }
308            Some(components.join("::"))
309        }
310        "python" => {
311            let stem = components.last()?.strip_suffix(".py")?.to_string();
312            *components.last_mut()? = stem.clone();
313            if stem == "__init__" {
314                components.pop();
315                if components.is_empty() {
316                    return None;
317                }
318            }
319            Some(components.join("."))
320        }
321        "typescript" => {
322            let last = components.last()?;
323            let stripped = last
324                .strip_suffix(".ts")
325                .or_else(|| last.strip_suffix(".tsx"))?
326                .to_string();
327            *components.last_mut()? = stripped;
328            Some(components.join("/"))
329        }
330        _ => None,
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337
338    #[test]
339    fn rust_use_extracts_crate_path() {
340        let content = "use serde::Serialize;\nuse crate::foo::Bar;\n";
341        let raw = extract_raw_imports("rust", content);
342        assert!(raw.contains(&"serde::Serialize".to_string()));
343        assert!(raw.contains(&"crate::foo::Bar".to_string()));
344    }
345
346    #[test]
347    fn rust_classify_external_vs_intra() {
348        assert_eq!(
349            classify_import("rust", "serde::Serialize", "crate", "mycrate"),
350            Resolved::ExternalProject("serde".to_string())
351        );
352        assert_eq!(
353            classify_import("rust", "mycrate::foo::Bar", "crate", "mycrate"),
354            Resolved::IntraModule("foo::Bar".to_string())
355        );
356        assert_eq!(
357            classify_import("rust", "std::collections::HashMap", "crate", "mycrate"),
358            Resolved::Skip
359        );
360    }
361
362    #[test]
363    fn python_classify_relative_import() {
364        assert_eq!(
365            classify_import("python", ".sibling", "pkg.mod", "pkg"),
366            Resolved::IntraModule("pkg.sibling".to_string())
367        );
368        assert_eq!(
369            classify_import("python", "requests", "pkg.mod", "pkg"),
370            Resolved::ExternalProject("requests".to_string())
371        );
372    }
373
374    #[test]
375    fn typescript_classify_relative_vs_package() {
376        assert_eq!(
377            classify_import("typescript", "./util", "", ""),
378            Resolved::IntraModule("./util".to_string())
379        );
380        assert_eq!(
381            classify_import("typescript", "left-pad", "", ""),
382            Resolved::ExternalProject("left-pad".to_string())
383        );
384    }
385
386    #[test]
387    fn module_path_for_rust_lib_root_is_crate() {
388        let root = Path::new("/proj");
389        let file = Path::new("/proj/src/lib.rs");
390        assert_eq!(
391            module_path_for_file(file, root, "rust"),
392            Some("crate".to_string())
393        );
394    }
395
396    #[test]
397    fn module_path_for_rust_nested_module() {
398        let root = Path::new("/proj");
399        let file = Path::new("/proj/src/foo/bar.rs");
400        assert_eq!(
401            module_path_for_file(file, root, "rust"),
402            Some("foo::bar".to_string())
403        );
404    }
405
406    #[test]
407    fn module_path_for_python_init_uses_parent() {
408        let root = Path::new("/proj");
409        let file = Path::new("/proj/pkg/__init__.py");
410        assert_eq!(
411            module_path_for_file(file, root, "python"),
412            Some("pkg".to_string())
413        );
414    }
415}