Skip to content

Commit 30822df

Browse files
committed
feat(scanner): workspace-aware Rust import resolution
resolve_imports now consumes the WorkspaceMap built in slice 5. Two behavior changes flow from it: 1. crate::X resolves against the importing member's src/ rather than whichever src/ the path-walk hits first. crate::module from crates/alpha/src/lib.rs lands in crates/alpha/src/, never the sibling crate's directory. 2. use my_workspace_crate::Foo classifies as Local (was External) and resolves into that member's src/. rust_target_is_local accepts a workspace member's first segment alongside crate::, self::, super::. Single-crate / non-Rust projects keep the pre-workspace behavior (empty WorkspaceMap -> the existing path-walk fallback runs). No schema bump; baseline drift is expected for real workspaces (more Local edges, possibly more cycles surfaced).
1 parent 8fc6d7e commit 30822df

2 files changed

Lines changed: 250 additions & 26 deletions

File tree

‎src/scanner.rs‎

Lines changed: 238 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -78,10 +78,12 @@ pub fn scan_path_with_config(
7878
source,
7979
})?;
8080
let config = load_project_plugins(&root, config)?;
81-
// Workspace discovery runs once per scan. The map is built eagerly
82-
// so future slices can wire it into `crate::` resolution and
83-
// cross-crate import classification without changing this seam.
84-
let _workspace = crate::workspace::discover(&root, &config);
81+
// Workspace discovery runs once per scan and feeds Rust resolution
82+
// so `crate::X` targets the importing member's `src/` and
83+
// `use my_workspace_crate::X` classifies as Local instead of
84+
// External. Empty workspace map -> resolution falls back to the
85+
// pre-workspace generic path-walk.
86+
let workspace = crate::workspace::discover(&root, &config);
8587

8688
let mut files = Vec::new();
8789
let mut functions = Vec::new();
@@ -256,7 +258,7 @@ pub fn scan_path_with_config(
256258

257259
let alias_map = build_alias_map(&root, &config);
258260
apply_alias_rewrites(&mut imports, &files, &alias_map);
259-
resolve_imports(&files, &mut imports, &config);
261+
resolve_imports(&files, &mut imports, &config, &workspace);
260262
let call_edges = resolve_call_edges(&files, &functions, &calls);
261263

262264
let snapshot_id = snapshot_id(&files);
@@ -3210,7 +3212,12 @@ fn apply_alias_replacement(replacement: &str, suffix: &str) -> String {
32103212
}
32113213
}
32123214

3213-
fn resolve_imports(files: &[FileFact], imports: &mut [ImportFact], config: &RaysenseConfig) {
3215+
fn resolve_imports(
3216+
files: &[FileFact],
3217+
imports: &mut [ImportFact],
3218+
config: &RaysenseConfig,
3219+
workspace: &crate::workspace::WorkspaceMap,
3220+
) {
32143221
let mut by_path = HashMap::new();
32153222
let mut by_module = HashMap::new();
32163223
let profile = ProjectProfile::infer(files);
@@ -3234,8 +3241,9 @@ fn resolve_imports(files: &[FileFact], imports: &mut [ImportFact], config: &Rays
32343241
&by_module,
32353242
&profile.include_roots,
32363243
config,
3244+
workspace,
32373245
);
3238-
import.resolution = classify_import(from_file, import, config);
3246+
import.resolution = classify_import(from_file, import, config, workspace);
32393247
}
32403248
}
32413249

@@ -3324,6 +3332,7 @@ fn classify_import(
33243332
from_file: &FileFact,
33253333
import: &ImportFact,
33263334
config: &RaysenseConfig,
3335+
workspace: &crate::workspace::WorkspaceMap,
33273336
) -> ImportResolution {
33283337
if import.resolved_file.is_some() {
33293338
return ImportResolution::Local;
@@ -3344,7 +3353,9 @@ fn classify_import(
33443353
{
33453354
ImportResolution::Local
33463355
}
3347-
Language::Rust if rust_target_is_local(&import.target) => ImportResolution::Unresolved,
3356+
Language::Rust if rust_target_is_local(&import.target, workspace) => {
3357+
ImportResolution::Unresolved
3358+
}
33483359
Language::TypeScript if import.target.starts_with('.') => ImportResolution::Unresolved,
33493360
Language::Python if import.target.starts_with('.') => ImportResolution::Unresolved,
33503361
Language::C | Language::Cpp if import.kind == "include" => ImportResolution::Unresolved,
@@ -3359,8 +3370,9 @@ fn resolve_import(
33593370
by_module: &HashMap<String, usize>,
33603371
include_roots: &[PathBuf],
33613372
config: &RaysenseConfig,
3373+
workspace: &crate::workspace::WorkspaceMap,
33623374
) -> Option<usize> {
3363-
let candidates = import_candidates(from_file, import, include_roots, config);
3375+
let candidates = import_candidates(from_file, import, include_roots, config, workspace);
33643376
candidates
33653377
.iter()
33663378
.find_map(|candidate| by_path.get(candidate).copied())
@@ -3374,9 +3386,10 @@ fn import_candidates(
33743386
import: &ImportFact,
33753387
include_roots: &[PathBuf],
33763388
config: &RaysenseConfig,
3389+
workspace: &crate::workspace::WorkspaceMap,
33773390
) -> Vec<String> {
33783391
match from_file.language {
3379-
Language::Rust => rust_import_candidates(&from_file.path, &import.target),
3392+
Language::Rust => rust_import_candidates(&from_file.path, &import.target, workspace),
33803393
Language::Python => python_import_candidates(&import.target),
33813394
Language::TypeScript => typescript_import_candidates(&from_file.path, &import.target),
33823395
Language::C | Language::Cpp => {
@@ -3473,21 +3486,57 @@ fn plugin_import_candidates(
34733486
candidates
34743487
}
34753488

3476-
fn rust_import_candidates(from_path: &Path, target: &str) -> Vec<String> {
3477-
if !rust_target_is_local(target) {
3489+
fn rust_import_candidates(
3490+
from_path: &Path,
3491+
target: &str,
3492+
workspace: &crate::workspace::WorkspaceMap,
3493+
) -> Vec<String> {
3494+
if !rust_target_is_local(target, workspace) {
34783495
return Vec::new();
34793496
}
34803497

3481-
let target = normalize_rust_target(target);
34823498
let mut candidates = Vec::new();
34833499

3500+
// Cross-crate target: first segment is a workspace member name.
3501+
// `use beta::thing` -> search inside `beta`'s `src/` rather than
3502+
// walking from the importer's path.
3503+
let trimmed = target.trim();
3504+
if !trimmed.starts_with("crate::")
3505+
&& !trimmed.starts_with("self::")
3506+
&& !trimmed.starts_with("super::")
3507+
{
3508+
if let Some((first, rest)) = trimmed.split_once("::") {
3509+
if let Some(member) = workspace.members_by_crate.get(first) {
3510+
let prefix_target = rest.replace("::", "/");
3511+
let prefix_target = prefix_target
3512+
.split('/')
3513+
.filter(|segment| !segment.is_empty())
3514+
.collect::<Vec<_>>()
3515+
.join("/");
3516+
if !prefix_target.is_empty() {
3517+
candidates.push(normalize_path(
3518+
member.src_dir.join(format!("{prefix_target}.rs")),
3519+
));
3520+
candidates.push(normalize_path(
3521+
member.src_dir.join(format!("{prefix_target}/mod.rs")),
3522+
));
3523+
}
3524+
candidates.push(normalize_path(member.src_dir.join("lib.rs")));
3525+
candidates.push(normalize_path(member.src_dir.join("mod.rs")));
3526+
return candidates;
3527+
}
3528+
}
3529+
}
3530+
3531+
let target = normalize_rust_target(target);
3532+
34843533
for prefix in rust_module_prefixes(&target) {
34853534
candidates.push(format!("{prefix}.rs"));
34863535
candidates.push(format!("{prefix}/mod.rs"));
34873536
candidates.push(format!("src/{prefix}.rs"));
34883537
candidates.push(format!("src/{prefix}/mod.rs"));
34893538

3490-
if let Some(crate_src) = rust_crate_src_dir(from_path) {
3539+
if let Some(crate_src) = rust_crate_src_dir(from_path, workspace) {
34913540
candidates.push(normalize_path(crate_src.join(format!("{prefix}.rs"))));
34923541
candidates.push(normalize_path(crate_src.join(format!("{prefix}/mod.rs"))));
34933542
}
@@ -3496,14 +3545,24 @@ fn rust_import_candidates(from_path: &Path, target: &str) -> Vec<String> {
34963545
candidates
34973546
}
34983547

3499-
fn rust_target_is_local(target: &str) -> bool {
3548+
fn rust_target_is_local(target: &str, workspace: &crate::workspace::WorkspaceMap) -> bool {
35003549
let target = target.trim();
3501-
target.starts_with("crate::")
3550+
if target.starts_with("crate::")
35023551
|| target.starts_with("self::")
35033552
|| target.starts_with("super::")
35043553
|| target == "super"
35053554
|| target == "self"
35063555
|| !target.contains("::")
3556+
{
3557+
return true;
3558+
}
3559+
// Cross-crate workspace target: `use my_workspace_crate::Foo`.
3560+
if let Some((first, _)) = target.split_once("::") {
3561+
if workspace.members_by_crate.contains_key(first) {
3562+
return true;
3563+
}
3564+
}
3565+
false
35073566
}
35083567

35093568
fn normalize_rust_target(target: &str) -> String {
@@ -3543,7 +3602,48 @@ fn rust_module_prefixes(target: &str) -> Vec<String> {
35433602
.collect()
35443603
}
35453604

3546-
fn rust_crate_src_dir(from_path: &Path) -> Option<PathBuf> {
3605+
fn rust_crate_src_dir(
3606+
from_path: &Path,
3607+
workspace: &crate::workspace::WorkspaceMap,
3608+
) -> Option<PathBuf> {
3609+
// Workspace-aware path: use the importing member's `src/` so
3610+
// `crate::X` from `crates/a/src/lib.rs` resolves under
3611+
// `crates/a/src/` rather than whatever `src/` raysense hits first.
3612+
if !workspace.members_by_crate.is_empty() {
3613+
let normalized = normalize_path(from_path);
3614+
let normalized = normalized.trim_end_matches('/');
3615+
let mut best: Option<&crate::workspace::MemberCrate> = None;
3616+
for member in workspace.members_by_crate.values() {
3617+
let manifest_dir = normalize_path(&member.manifest_dir);
3618+
let manifest_dir = manifest_dir.trim_end_matches('/');
3619+
let prefix = if manifest_dir.is_empty() || manifest_dir == "." {
3620+
String::new()
3621+
} else {
3622+
format!("{manifest_dir}/")
3623+
};
3624+
if prefix.is_empty() || normalized.starts_with(&prefix) {
3625+
let len = prefix.len();
3626+
if best
3627+
.map(|m| {
3628+
let cur = normalize_path(&m.manifest_dir);
3629+
let cur = cur.trim_end_matches('/');
3630+
let cur_len = if cur.is_empty() || cur == "." {
3631+
0
3632+
} else {
3633+
cur.len() + 1
3634+
};
3635+
len > cur_len
3636+
})
3637+
.unwrap_or(true)
3638+
{
3639+
best = Some(member);
3640+
}
3641+
}
3642+
}
3643+
if let Some(member) = best {
3644+
return Some(member.src_dir.clone());
3645+
}
3646+
}
35473647
let mut out = PathBuf::new();
35483648
for component in from_path.components() {
35493649
let Component::Normal(part) = component else {
@@ -4110,6 +4210,97 @@ fn helper() {}
41104210
assert_eq!(imports[0].alias.as_deref(), Some("Baz"));
41114211
}
41124212

4213+
#[test]
4214+
fn workspace_internal_use_resolves_as_local() {
4215+
let root = temp_scan_root("workspace_local");
4216+
fs::create_dir_all(&root).unwrap();
4217+
std::fs::write(
4218+
root.join("Cargo.toml"),
4219+
"[workspace]\nmembers = [\"crates/alpha\", \"crates/beta\"]\n",
4220+
)
4221+
.unwrap();
4222+
for (member, name, body) in [
4223+
(
4224+
"crates/alpha",
4225+
"alpha",
4226+
"use beta::thing;\npub fn use_beta() { let _ = thing(); }\n",
4227+
),
4228+
("crates/beta", "beta", "pub fn thing() {}\n"),
4229+
] {
4230+
let manifest_dir = root.join(member);
4231+
fs::create_dir_all(manifest_dir.join("src")).unwrap();
4232+
fs::write(
4233+
manifest_dir.join("Cargo.toml"),
4234+
format!("[package]\nname = \"{name}\"\nversion = \"0.0.0\"\nedition = \"2021\"\n"),
4235+
)
4236+
.unwrap();
4237+
fs::write(manifest_dir.join("src/lib.rs"), body).unwrap();
4238+
}
4239+
4240+
let report = scan_path_with_config(&root, &RaysenseConfig::default()).unwrap();
4241+
fs::remove_dir_all(&root).unwrap();
4242+
4243+
let beta_thing_import = report
4244+
.imports
4245+
.iter()
4246+
.find(|import| import.target == "beta::thing")
4247+
.expect("the use beta::thing import is captured");
4248+
assert_eq!(
4249+
beta_thing_import.resolution,
4250+
ImportResolution::Local,
4251+
"cross-crate workspace import resolves as Local instead of External"
4252+
);
4253+
assert!(beta_thing_import.resolved_file.is_some());
4254+
}
4255+
4256+
#[test]
4257+
fn workspace_crate_self_import_uses_member_src_dir() {
4258+
let root = temp_scan_root("workspace_crate_self");
4259+
fs::create_dir_all(&root).unwrap();
4260+
std::fs::write(
4261+
root.join("Cargo.toml"),
4262+
"[workspace]\nmembers = [\"crates/alpha\", \"crates/beta\"]\n",
4263+
)
4264+
.unwrap();
4265+
// Both members declare a `module` sibling. The importer in
4266+
// crates/alpha/src/lib.rs uses `crate::module`, which must
4267+
// resolve to crates/alpha/src/module.rs (its own member's
4268+
// source root) -- never crates/beta/src/module.rs.
4269+
for member in ["crates/alpha", "crates/beta"] {
4270+
let name = member.rsplit('/').next().unwrap();
4271+
let manifest_dir = root.join(member);
4272+
fs::create_dir_all(manifest_dir.join("src")).unwrap();
4273+
fs::write(
4274+
manifest_dir.join("Cargo.toml"),
4275+
format!("[package]\nname = \"{name}\"\nversion = \"0.0.0\"\nedition = \"2021\"\n"),
4276+
)
4277+
.unwrap();
4278+
fs::write(manifest_dir.join("src/module.rs"), "pub fn item() {}\n").unwrap();
4279+
}
4280+
fs::write(root.join("crates/alpha/src/lib.rs"), "use crate::module;\n").unwrap();
4281+
fs::write(root.join("crates/beta/src/lib.rs"), "").unwrap();
4282+
4283+
let report = scan_path_with_config(&root, &RaysenseConfig::default()).unwrap();
4284+
fs::remove_dir_all(&root).unwrap();
4285+
4286+
let import = report
4287+
.imports
4288+
.iter()
4289+
.find(|import| import.target == "crate::module")
4290+
.expect("crate::module import captured");
4291+
let resolved_id = import.resolved_file.expect("resolved against alpha's src");
4292+
let resolved_path = report
4293+
.files
4294+
.iter()
4295+
.find(|file| file.file_id == resolved_id)
4296+
.map(|file| file.path.to_string_lossy().to_string())
4297+
.unwrap();
4298+
assert!(
4299+
resolved_path.contains("crates/alpha/src/module.rs"),
4300+
"crate:: resolution must use the importing member's src dir, got {resolved_path}"
4301+
);
4302+
}
4303+
41134304
#[test]
41144305
fn extracts_rust_trait_impl_single() {
41154306
let content = "trait Greet {}\nstruct Dog;\nimpl Greet for Dog {}\n";
@@ -4940,7 +5131,12 @@ int run(void) {
49405131
];
49415132
let mut imports = vec![new_import(0, "crate::graph", "use")];
49425133

4943-
resolve_imports(&files, &mut imports, &RaysenseConfig::default());
5134+
resolve_imports(
5135+
&files,
5136+
&mut imports,
5137+
&RaysenseConfig::default(),
5138+
&crate::workspace::WorkspaceMap::default(),
5139+
);
49445140

49455141
assert_eq!(imports[0].resolved_file, Some(1));
49465142
assert_eq!(imports[0].resolution, ImportResolution::Local);
@@ -4958,7 +5154,12 @@ int run(void) {
49585154
new_import(0, "../widgets", "import"),
49595155
];
49605156

4961-
resolve_imports(&files, &mut imports, &RaysenseConfig::default());
5157+
resolve_imports(
5158+
&files,
5159+
&mut imports,
5160+
&RaysenseConfig::default(),
5161+
&crate::workspace::WorkspaceMap::default(),
5162+
);
49625163

49635164
assert_eq!(imports[0].resolved_file, Some(1));
49645165
assert_eq!(imports[1].resolved_file, Some(2));
@@ -4974,7 +5175,12 @@ int run(void) {
49745175
];
49755176
let mut imports = vec![new_import(0, "crate::memory", "use")];
49765177

4977-
resolve_imports(&files, &mut imports, &RaysenseConfig::default());
5178+
resolve_imports(
5179+
&files,
5180+
&mut imports,
5181+
&RaysenseConfig::default(),
5182+
&crate::workspace::WorkspaceMap::default(),
5183+
);
49785184

49795185
assert_eq!(imports[0].resolved_file, Some(1));
49805186
assert_eq!(imports[0].resolution, ImportResolution::Local);
@@ -4985,7 +5191,12 @@ int run(void) {
49855191
let files = vec![file(0, "src/main.rs", Language::Rust)];
49865192
let mut imports = vec![new_import(0, "serde::Serialize", "use")];
49875193

4988-
resolve_imports(&files, &mut imports, &RaysenseConfig::default());
5194+
resolve_imports(
5195+
&files,
5196+
&mut imports,
5197+
&RaysenseConfig::default(),
5198+
&crate::workspace::WorkspaceMap::default(),
5199+
);
49895200

49905201
assert_eq!(imports[0].resolved_file, None);
49915202
assert_eq!(imports[0].resolution, ImportResolution::External);
@@ -5005,7 +5216,12 @@ int run(void) {
50055216
new_import(0, "missing.h", "include"),
50065217
];
50075218

5008-
resolve_imports(&files, &mut imports, &RaysenseConfig::default());
5219+
resolve_imports(
5220+
&files,
5221+
&mut imports,
5222+
&RaysenseConfig::default(),
5223+
&crate::workspace::WorkspaceMap::default(),
5224+
);
50095225

50105226
assert_eq!(imports[0].resolution, ImportResolution::System);
50115227
assert_eq!(imports[1].resolved_file, Some(1));

0 commit comments

Comments
 (0)