Skip to main content

tokmd_analysis_imports/
lib.rs

1//! Language-aware import extraction and deterministic target normalization.
2//!
3//! This crate intentionally keeps only parsing and normalization logic for
4//! import-like statements so higher-tier crates can compose it without
5//! filesystem or receipt dependencies.
6
7#![forbid(unsafe_code)]
8
9/// Returns true when `lang` supports import extraction.
10pub fn supports_language(lang: &str) -> bool {
11    matches!(
12        lang.to_ascii_lowercase().as_str(),
13        "rust" | "javascript" | "typescript" | "python" | "go"
14    )
15}
16
17/// Extract import-like targets from language-specific source lines.
18pub fn parse_imports<S: AsRef<str>>(lang: &str, lines: &[S]) -> Vec<String> {
19    match lang.to_ascii_lowercase().as_str() {
20        "rust" => parse_rust_imports(lines),
21        "javascript" | "typescript" => parse_js_imports(lines),
22        "python" => parse_py_imports(lines),
23        "go" => parse_go_imports(lines),
24        _ => Vec::new(),
25    }
26}
27
28/// Normalize an import target into a stable dependency root.
29///
30/// Relative imports are collapsed to `local`.
31pub fn normalize_import_target(target: &str) -> String {
32    let trimmed = target.trim();
33    if trimmed.starts_with('.') {
34        return "local".to_string();
35    }
36    let trimmed = trimmed.trim_matches('"').trim_matches('\'');
37    trimmed
38        .split(['/', ':', '.'])
39        .next()
40        .unwrap_or(trimmed)
41        .to_string()
42}
43
44fn parse_rust_imports<S: AsRef<str>>(lines: &[S]) -> Vec<String> {
45    let mut imports = Vec::new();
46    for line in lines {
47        let trimmed = line.as_ref().trim();
48        if trimmed.starts_with("use ")
49            && let Some(rest) = trimmed.strip_prefix("use ")
50        {
51            let rest = rest.trim_end_matches(';').trim();
52            let target = rest.split("::").next().unwrap_or(rest).to_string();
53            imports.push(target);
54        } else if trimmed.starts_with("mod ")
55            && let Some(rest) = trimmed.strip_prefix("mod ")
56        {
57            let target = rest.trim_end_matches(';').trim().to_string();
58            imports.push(target);
59        }
60    }
61    imports
62}
63
64fn parse_js_imports<S: AsRef<str>>(lines: &[S]) -> Vec<String> {
65    let mut imports = Vec::new();
66    for line in lines {
67        let trimmed = line.as_ref().trim();
68        if trimmed.starts_with("import ")
69            && let Some(target) = extract_quoted(trimmed)
70        {
71            imports.push(target);
72        }
73        if let Some(idx) = trimmed.find("require(")
74            && let Some(target) = extract_quoted(&trimmed[idx..])
75        {
76            imports.push(target);
77        }
78    }
79    imports
80}
81
82fn parse_py_imports<S: AsRef<str>>(lines: &[S]) -> Vec<String> {
83    let mut imports = Vec::new();
84    for line in lines {
85        let trimmed = line.as_ref().trim();
86        if trimmed.starts_with("import ")
87            && let Some(rest) = trimmed.strip_prefix("import ")
88        {
89            let target = rest.split_whitespace().next().unwrap_or(rest).to_string();
90            imports.push(target);
91        } else if trimmed.starts_with("from ")
92            && let Some(rest) = trimmed.strip_prefix("from ")
93        {
94            let target = rest.split_whitespace().next().unwrap_or(rest).to_string();
95            imports.push(target);
96        }
97    }
98    imports
99}
100
101fn parse_go_imports<S: AsRef<str>>(lines: &[S]) -> Vec<String> {
102    let mut imports = Vec::new();
103    let mut in_block = false;
104    for line in lines {
105        let trimmed = line.as_ref().trim();
106        if trimmed.starts_with("import (") {
107            in_block = true;
108            continue;
109        }
110        if in_block {
111            if trimmed.starts_with(')') {
112                in_block = false;
113                continue;
114            }
115            if let Some(target) = extract_quoted(trimmed) {
116                imports.push(target);
117            }
118            continue;
119        }
120        if trimmed.starts_with("import ")
121            && let Some(target) = extract_quoted(trimmed)
122        {
123            imports.push(target);
124        }
125    }
126    imports
127}
128
129fn extract_quoted(text: &str) -> Option<String> {
130    let mut chars = text.chars();
131    let mut quote = None;
132    for ch in chars.by_ref() {
133        if ch == '"' || ch == '\'' {
134            quote = Some(ch);
135            break;
136        }
137    }
138    let quote = quote?;
139    let mut out = String::new();
140    for ch in chars {
141        if ch == quote {
142            break;
143        }
144        out.push(ch);
145    }
146    if out.is_empty() { None } else { Some(out) }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    // ---- supports_language ----
154
155    #[test]
156    fn test_supports_known_languages() {
157        assert!(supports_language("Rust"));
158        assert!(supports_language("rust"));
159        assert!(supports_language("JavaScript"));
160        assert!(supports_language("TypeScript"));
161        assert!(supports_language("Python"));
162        assert!(supports_language("Go"));
163    }
164
165    #[test]
166    fn test_unsupported_languages() {
167        assert!(!supports_language("Java"));
168        assert!(!supports_language("C"));
169        assert!(!supports_language("C++"));
170        assert!(!supports_language("Ruby"));
171        assert!(!supports_language(""));
172    }
173
174    // ---- normalize_import_target ----
175
176    #[test]
177    fn test_normalize_relative_to_local() {
178        assert_eq!(normalize_import_target("./utils"), "local");
179        assert_eq!(normalize_import_target("../lib"), "local");
180        assert_eq!(normalize_import_target("."), "local");
181    }
182
183    #[test]
184    fn test_normalize_npm_scoped_package() {
185        assert_eq!(normalize_import_target("react/dom"), "react");
186        assert_eq!(normalize_import_target("lodash/fp"), "lodash");
187    }
188
189    #[test]
190    fn test_normalize_rust_crate() {
191        assert_eq!(normalize_import_target("std::collections"), "std");
192        assert_eq!(normalize_import_target("serde::Deserialize"), "serde");
193    }
194
195    #[test]
196    fn test_normalize_python_dotted() {
197        assert_eq!(normalize_import_target("os.path"), "os");
198        assert_eq!(normalize_import_target("collections.abc"), "collections");
199    }
200
201    #[test]
202    fn test_normalize_strips_quotes() {
203        assert_eq!(normalize_import_target("\"react\""), "react");
204        assert_eq!(normalize_import_target("'lodash'"), "lodash");
205    }
206
207    #[test]
208    fn test_normalize_trims_whitespace() {
209        assert_eq!(normalize_import_target("  react  "), "react");
210    }
211
212    // ---- parse_imports: Rust ----
213
214    #[test]
215    fn test_parse_rust_use_statement() {
216        let lines = ["use std::collections::HashMap;", "use serde::Deserialize;"];
217        let imports = parse_imports("Rust", &lines);
218        assert_eq!(imports, vec!["std", "serde"]);
219    }
220
221    #[test]
222    fn test_parse_rust_mod_statement() {
223        let lines = ["mod utils;", "mod tests;"];
224        let imports = parse_imports("Rust", &lines);
225        assert_eq!(imports, vec!["utils", "tests"]);
226    }
227
228    #[test]
229    fn test_parse_rust_mixed() {
230        let lines = [
231            "use anyhow::Result;",
232            "mod config;",
233            "fn main() {}",
234            "use tokei::Languages;",
235        ];
236        let imports = parse_imports("rust", &lines);
237        assert_eq!(imports, vec!["anyhow", "config", "tokei"]);
238    }
239
240    #[test]
241    fn test_parse_rust_ignores_non_import_lines() {
242        let lines = ["fn main() {}", "let x = 42;", "// use fake;"];
243        let imports = parse_imports("Rust", &lines);
244        assert!(imports.is_empty());
245    }
246
247    // ---- parse_imports: JavaScript/TypeScript ----
248
249    #[test]
250    fn test_parse_js_import_from() {
251        let lines = [
252            "import React from 'react';",
253            "import { useState } from \"react\";",
254        ];
255        let imports = parse_imports("JavaScript", &lines);
256        assert_eq!(imports, vec!["react", "react"]);
257    }
258
259    #[test]
260    fn test_parse_js_require() {
261        let lines = [
262            "const fs = require('fs');",
263            "const path = require(\"path\");",
264        ];
265        let imports = parse_imports("JavaScript", &lines);
266        assert_eq!(imports, vec!["fs", "path"]);
267    }
268
269    #[test]
270    fn test_parse_ts_imports() {
271        let lines = ["import type { Foo } from 'bar';"];
272        let imports = parse_imports("TypeScript", &lines);
273        assert_eq!(imports, vec!["bar"]);
274    }
275
276    // ---- parse_imports: Python ----
277
278    #[test]
279    fn test_parse_python_import() {
280        let lines = ["import os", "import sys"];
281        let imports = parse_imports("Python", &lines);
282        assert_eq!(imports, vec!["os", "sys"]);
283    }
284
285    #[test]
286    fn test_parse_python_from_import() {
287        let lines = [
288            "from pathlib import Path",
289            "from collections import defaultdict",
290        ];
291        let imports = parse_imports("Python", &lines);
292        assert_eq!(imports, vec!["pathlib", "collections"]);
293    }
294
295    #[test]
296    fn test_parse_python_ignores_comments() {
297        let lines = ["# import fake", "import os"];
298        let imports = parse_imports("Python", &lines);
299        assert_eq!(imports, vec!["os"]);
300    }
301
302    // ---- parse_imports: Go ----
303
304    #[test]
305    fn test_parse_go_single_import() {
306        let lines = ["import \"fmt\""];
307        let imports = parse_imports("Go", &lines);
308        assert_eq!(imports, vec!["fmt"]);
309    }
310
311    #[test]
312    fn test_parse_go_block_import() {
313        let lines = ["import (", "\t\"fmt\"", "\t\"os\"", ")"];
314        let imports = parse_imports("Go", &lines);
315        assert_eq!(imports, vec!["fmt", "os"]);
316    }
317
318    #[test]
319    fn test_parse_go_std_and_external() {
320        let lines = ["import (", "\t\"fmt\"", "\t\"github.com/pkg/errors\"", ")"];
321        let imports = parse_imports("Go", &lines);
322        assert_eq!(imports, vec!["fmt", "github.com/pkg/errors"]);
323    }
324
325    // ---- parse_imports: unsupported language ----
326
327    #[test]
328    fn test_parse_unsupported_language_returns_empty() {
329        let lines = ["#include <stdio.h>"];
330        let imports = parse_imports("C", &lines);
331        assert!(imports.is_empty());
332    }
333
334    // ---- parse_imports: empty input ----
335
336    #[test]
337    fn test_parse_empty_input() {
338        let lines: Vec<&str> = vec![];
339        let imports = parse_imports("Rust", &lines);
340        assert!(imports.is_empty());
341    }
342
343    // ---- extract_quoted ----
344
345    #[test]
346    fn test_extract_quoted_double() {
347        assert_eq!(extract_quoted("from \"hello\""), Some("hello".to_string()));
348    }
349
350    #[test]
351    fn test_extract_quoted_single() {
352        assert_eq!(extract_quoted("from 'world'"), Some("world".to_string()));
353    }
354
355    #[test]
356    fn test_extract_quoted_empty_string() {
357        assert_eq!(extract_quoted("\"\""), None);
358    }
359
360    #[test]
361    fn test_extract_quoted_no_quotes() {
362        assert_eq!(extract_quoted("no quotes here"), None);
363    }
364}