Skip to main content

semantic/analysis/
analysis_imports.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Import/dependency change detection.
3
4use std::collections::{HashMap, HashSet};
5
6use objects::object::SemanticChange;
7
8use crate::parser::{Language, ParsedFile};
9
10/// Detect import/dependency changes between two file versions.
11///
12/// If `manifest_content` is provided (e.g. contents of `Cargo.toml` or `package.json`),
13/// dependency versions will be resolved from it instead of showing "unknown".
14pub fn detect_import_changes(
15    old_path: &std::path::Path,
16    new_path: &std::path::Path,
17    old_content: &str,
18    new_content: &str,
19) -> Vec<SemanticChange> {
20    detect_import_changes_with_manifest(old_path, new_path, old_content, new_content, None)
21}
22
23/// Detect import changes with optional manifest for version resolution.
24pub fn detect_import_changes_with_manifest(
25    old_path: &std::path::Path,
26    new_path: &std::path::Path,
27    old_content: &str,
28    new_content: &str,
29    manifest_content: Option<&str>,
30) -> Vec<SemanticChange> {
31    let old_parsed = ParsedFile::parse(old_content, Language::from_path(old_path));
32    let new_parsed = ParsedFile::parse(new_content, Language::from_path(new_path));
33
34    detect_import_changes_with_parsed(
35        old_path,
36        new_path,
37        old_parsed.as_ref(),
38        new_parsed.as_ref(),
39        manifest_content,
40    )
41}
42
43pub(crate) fn detect_import_changes_with_parsed(
44    _old_path: &std::path::Path,
45    new_path: &std::path::Path,
46    old_parsed: Option<&ParsedFile>,
47    new_parsed: Option<&ParsedFile>,
48    manifest_content: Option<&str>,
49) -> Vec<SemanticChange> {
50    let mut changes = Vec::new();
51
52    let old_imports: HashSet<String> = old_parsed
53        .map(|p| p.extract_imports().into_iter().map(|i| i.raw).collect())
54        .unwrap_or_default();
55
56    let new_imports: HashSet<String> = new_parsed
57        .map(|p| p.extract_imports().into_iter().map(|i| i.raw).collect())
58        .unwrap_or_default();
59
60    let versions = manifest_content
61        .map(|m| parse_manifest_versions(m, Language::from_path(new_path)))
62        .unwrap_or_default();
63
64    let old_deps = dependency_names(&old_imports);
65    let new_deps = dependency_names(&new_imports);
66
67    for dep_name in new_deps.difference(&old_deps) {
68        let version = versions
69            .get(dep_name)
70            .cloned()
71            .unwrap_or_else(|| "unknown".to_string());
72        changes.push(SemanticChange::DependencyAdded {
73            name: dep_name.clone(),
74            version,
75        });
76    }
77
78    for dep_name in old_deps.difference(&new_deps) {
79        changes.push(SemanticChange::DependencyRemoved {
80            name: dep_name.clone(),
81        });
82    }
83
84    changes
85}
86
87fn dependency_names(imports: &HashSet<String>) -> HashSet<String> {
88    imports
89        .iter()
90        .filter_map(|import| extract_dependency_from_import(import))
91        .filter(|name| !is_stdlib_dependency(name))
92        .collect()
93}
94
95/// Parse dependency versions from a manifest file (Cargo.toml or package.json).
96fn parse_manifest_versions(content: &str, language: Language) -> HashMap<String, String> {
97    match language {
98        Language::Rust => parse_cargo_toml_versions(content),
99        Language::JavaScript | Language::TypeScript => parse_package_json_versions(content),
100        _ => HashMap::new(),
101    }
102}
103
104/// Extract crate versions from Cargo.toml.
105fn parse_cargo_toml_versions(content: &str) -> HashMap<String, String> {
106    let mut versions = HashMap::new();
107    // Simple line-based parser for [dependencies] sections.
108    let mut in_deps = false;
109    for line in content.lines() {
110        let trimmed = line.trim();
111        if trimmed.starts_with('[') {
112            in_deps = trimmed == "[dependencies]"
113                || trimmed == "[dev-dependencies]"
114                || trimmed == "[build-dependencies]";
115            continue;
116        }
117        if !in_deps {
118            continue;
119        }
120        // Handle: crate_name = "version"
121        if let Some((name, rest)) = trimmed.split_once('=') {
122            let name = name.trim().trim_matches('"');
123            let rest = rest.trim();
124            if let Some(version) = rest.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
125                versions.insert(name.to_string(), version.to_string());
126            }
127            // Handle: crate_name = { version = "X", ... }
128            else if rest.starts_with('{')
129                && let Some(start) = rest.find("version")
130            {
131                let after = &rest[start..];
132                if let Some(eq) = after.find('=') {
133                    let val = after[eq + 1..].trim().trim_start_matches('"');
134                    if let Some(end) = val.find('"') {
135                        versions.insert(name.to_string(), val[..end].to_string());
136                    }
137                }
138            }
139        }
140    }
141    versions
142}
143
144/// Extract dependency versions from package.json using simple line parsing.
145/// Handles the common `"name": "version"` pattern inside dependency sections.
146fn parse_package_json_versions(content: &str) -> HashMap<String, String> {
147    let mut versions = HashMap::new();
148    let mut in_deps = false;
149    let mut brace_depth: i32 = 0;
150    for line in content.lines() {
151        let trimmed = line.trim();
152        // Detect dependency sections.
153        if (trimmed.contains("\"dependencies\"")
154            || trimmed.contains("\"devDependencies\"")
155            || trimmed.contains("\"peerDependencies\""))
156            && trimmed.contains(':')
157        {
158            in_deps = true;
159            if trimmed.contains('{') {
160                brace_depth = 1;
161            }
162            continue;
163        }
164        if in_deps {
165            brace_depth += trimmed.matches('{').count() as i32;
166            brace_depth -= trimmed.matches('}').count() as i32;
167            if brace_depth <= 0 {
168                in_deps = false;
169                continue;
170            }
171            // Parse "name": "version"
172            if let Some((name_part, version_part)) = trimmed.split_once(':') {
173                let name = name_part.trim().trim_matches(|c| c == '"' || c == ',');
174                let version = version_part
175                    .trim()
176                    .trim_matches(|c| c == '"' || c == ',' || c == ' ');
177                if !name.is_empty() && !version.is_empty() && !version.starts_with('{') {
178                    versions.insert(name.to_string(), version.to_string());
179                }
180            }
181        }
182    }
183    versions
184}
185
186fn extract_dependency_from_import(import: &str) -> Option<String> {
187    let trimmed = import.trim();
188
189    if let Some(stripped) = trimmed.strip_prefix("use ") {
190        let path = stripped.trim_end_matches(';');
191        let first = path.split("::").next()?;
192        return Some(first.to_string());
193    }
194
195    if trimmed.starts_with("extern crate ") {
196        let parts: Vec<&str> = trimmed.split_whitespace().collect();
197        if parts.len() >= 3 {
198            return Some(parts[2].trim_end_matches(';').to_string());
199        }
200    }
201
202    None
203}
204
205fn is_stdlib_dependency(name: &str) -> bool {
206    matches!(name, "std" | "core" | "alloc" | "crate" | "super" | "self")
207}