Skip to content

Commit ee1f27c

Browse files
authored
fix(analysis): discover external types with visibility, attributes, and generics (#82) (#83)
The external-crate lookup introduced in #78 used a raw substring match (content.contains("struct X") / content.contains("enum X")), which produced both false negatives and false positives: - false negatives for multi-line declarations and any form a stricter keyword-anchored regex would miss - false positives where a longer identifier (e.g. ExternalFooBar) matched a search for a shorter one (e.g. ExternalFoo) Replace the substring heuristic with a syn-based AST match on the item identifier. A cheap contains(type_name) pre-filter still skips the vast majority of registry files before paying for a parse; candidate files are then parsed with syn::parse_file and checked for a struct/enum item whose ident exactly equals the requested type name. This transparently handles pub, pub(crate), #[derive(...)] attributes, generics, and multi-line declarations, and recurses into inline mod blocks. Files that fail to parse (macro-heavy/generated registry sources) are skipped instead of aborting the walk. Adds 9 regression tests covering pub(crate) visibility, derive attributes, generics, multi-line declarations, pub enum with attributes, nested modules, prefix false-positive avoidance, missing types, and unparseable files. Tests run #[serial] since they share the CARGO_HOME env var.
1 parent 9453f6b commit ee1f27c

1 file changed

Lines changed: 234 additions & 22 deletions

File tree

‎src/analysis/mod.rs‎

Lines changed: 234 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -378,14 +378,30 @@ impl CommandAnalyzer {
378378
Ok(())
379379
}
380380

381-
// Find type paths from external crates
381+
// Find type paths from external crates.
382+
//
383+
// Walks the Cargo registry source tree and, for each `.rs` file, looks for a
384+
// `struct` or `enum` item whose identifier exactly matches `type_name`. The
385+
// match is performed on the parsed AST (via `syn`) rather than with a raw
386+
// substring search, so it correctly handles visibility modifiers
387+
// (`pub`, `pub(crate)`), attributes (`#[derive(...)]`), generics
388+
// (`struct X<T>`), and multi-line declarations — all of which the previous
389+
// `content.contains("struct X")` heuristic missed or matched incorrectly
390+
// (see issue #82). A cheap `contains` pre-filter keeps the registry walk fast
391+
// by skipping files that cannot possibly declare the type.
382392
fn find_external_type_path(&self, type_name: &str) -> Option<PathBuf> {
383393
// Resolve Cargo home – fall back to $HOME/.cargo if the env var is missing.
384394
let cargo_home: String = env::var("CARGO_HOME")
385395
.or_else(|_| env::var("HOME").map(|h| format!("{}/.cargo", h)))
386396
.ok()?;
387397
let src_dir: PathBuf = PathBuf::from(cargo_home).join("registry/src");
388398

399+
// A cheap substring pre-filter: only the identifier, without the
400+
// `struct`/`enum` keyword, so that `pub struct X`, `pub(crate) enum X`,
401+
// and `struct\n X` all pass through to the AST check. This avoids parsing
402+
// the vast majority of registry files that cannot contain the type.
403+
let needle: String = type_name.to_string();
404+
389405
// Walk the registry tree depth‑first.
390406
let mut dirs: Vec<PathBuf> = vec![src_dir];
391407
while let Some(dir) = dirs.pop() {
@@ -399,19 +415,56 @@ impl CommandAnalyzer {
399415
if path.extension().and_then(|s| s.to_str()) != Some("rs") {
400416
continue;
401417
}
402-
// Cheap heuristic – read the file and look for the exact identifier.
403-
if let Ok(content) = fs::read_to_string(&path) {
404-
let struct_pat: String = format!("struct {}", type_name);
405-
let enum_pat: String = format!("enum {}", type_name);
406-
if content.contains(&struct_pat) || content.contains(&enum_pat) {
407-
return Some(path);
408-
}
418+
// Cheap pre-filter on the raw source before paying for a full parse.
419+
let content: String = match fs::read_to_string(&path) {
420+
Ok(c) => c,
421+
Err(_) => continue,
422+
};
423+
if !content.contains(&needle) {
424+
continue;
425+
}
426+
// Authoritative check on the parsed AST.
427+
if Self::file_declares_type(&content, type_name) {
428+
return Some(path);
409429
}
410430
}
411431
}
412432
None
413433
}
414434

435+
/// Returns `true` if `source` declares a `struct` or `enum` item whose
436+
/// identifier equals `type_name`. Items nested inside `mod` blocks are
437+
/// considered as well, mirroring how the analyzer indexes local modules.
438+
/// Files that fail to parse (e.g. macro-heavy or generated sources) yield
439+
/// `false` so they are simply skipped during the registry walk.
440+
fn file_declares_type(source: &str, type_name: &str) -> bool {
441+
let file: syn::File = match syn::parse_file(source) {
442+
Ok(f) => f,
443+
Err(_) => return false,
444+
};
445+
Self::items_declare_type(&file.items, type_name)
446+
}
447+
448+
fn items_declare_type(items: &[syn::Item], type_name: &str) -> bool {
449+
for item in items {
450+
let declares = match item {
451+
syn::Item::Struct(s) => s.ident == type_name,
452+
syn::Item::Enum(e) => e.ident == type_name,
453+
// Recurse into inline modules so types declared in `mod x { ... }`
454+
// blocks within a single file are still discovered.
455+
syn::Item::Mod(m) => match &m.content {
456+
Some((_, inner)) => Self::items_declare_type(inner, type_name),
457+
None => false,
458+
},
459+
_ => false,
460+
};
461+
if declares {
462+
return true;
463+
}
464+
}
465+
false
466+
}
467+
415468
/// Extract a specific type from a cached AST
416469
fn extract_type_from_ast(
417470
&mut self,
@@ -1173,31 +1226,190 @@ mod tests {
11731226

11741227
mod external_type_lookup {
11751228
use super::*;
1229+
use serial_test::serial;
11761230
use std::env;
11771231
use std::fs;
11781232
use std::path::PathBuf;
11791233

1234+
/// Build a fake Cargo registry rooted at a temp dir, write the given
1235+
/// source files into `registry/src/dummy-0.1.0/`, point `CARGO_HOME` at
1236+
/// it, and return the analyzer + the path that a declaration in
1237+
/// `lib.rs` should resolve to.
1238+
struct FakeRegistry {
1239+
_root: PathBuf,
1240+
cargo_home: PathBuf,
1241+
}
1242+
1243+
impl FakeRegistry {
1244+
fn new(files: &[(&str, &str)]) -> (Self, CommandAnalyzer) {
1245+
let root: PathBuf = std::env::temp_dir().join(format!(
1246+
"tauri_typegen_external_test_{}",
1247+
std::time::SystemTime::now()
1248+
.duration_since(std::time::UNIX_EPOCH)
1249+
.unwrap()
1250+
.as_nanos(),
1251+
));
1252+
let _ = std::fs::remove_dir_all(&root);
1253+
let cargo_home: PathBuf = root.join(".cargo");
1254+
let src_dir: PathBuf = cargo_home.join("registry/src");
1255+
let crate_dir: PathBuf = src_dir.join("dummy-0.1.0");
1256+
fs::create_dir_all(&crate_dir).expect("create temp crate dir");
1257+
for (name, content) in files {
1258+
fs::write(crate_dir.join(name), content).expect("write file");
1259+
}
1260+
env::set_var("CARGO_HOME", &cargo_home);
1261+
let fake = FakeRegistry {
1262+
_root: root.clone(),
1263+
cargo_home,
1264+
};
1265+
(fake, CommandAnalyzer::default())
1266+
}
1267+
}
1268+
1269+
impl Drop for FakeRegistry {
1270+
fn drop(&mut self) {
1271+
let _ = std::fs::remove_dir_all(&self._root);
1272+
}
1273+
}
1274+
11801275
#[test]
1181-
fn test_find_external_type_path() {
1182-
let tmp_root: PathBuf = std::env::temp_dir().join("tauri_typegen_external_test");
1183-
let _ = std::fs::remove_dir_all(&tmp_root);
1184-
let cargo_home: PathBuf = tmp_root.join(".cargo");
1185-
let src_dir: PathBuf = cargo_home.join("registry/src");
1186-
let crate_dir: PathBuf = src_dir.join("dummy-0.1.0");
1187-
fs::create_dir_all(&crate_dir).expect("create temp crate dir");
1276+
#[serial]
1277+
fn test_find_external_type_path_pub_struct() {
1278+
let (reg, analyzer) = FakeRegistry::new(&[("lib.rs", "pub struct ExternalFoo;")]);
1279+
let found = analyzer.find_external_type_path("ExternalFoo");
1280+
let expected = reg.cargo_home.join("registry/src/dummy-0.1.0/lib.rs");
1281+
assert_eq!(found.unwrap(), expected, "pub struct should be located");
1282+
}
11881283

1189-
let file_path: PathBuf = crate_dir.join("lib.rs");
1190-
fs::write(&file_path, "pub struct ExternalFoo;").expect("write dummy crate file");
1284+
/// Regression for issue #82: visibility modifiers such as `pub(crate)`
1285+
/// must not prevent discovery.
1286+
#[test]
1287+
#[serial]
1288+
fn test_find_external_type_path_pub_crate_visibility() {
1289+
let (reg, analyzer) = FakeRegistry::new(&[("lib.rs", "pub(crate) struct VisCrate;")]);
1290+
let found = analyzer.find_external_type_path("VisCrate");
1291+
let expected = reg.cargo_home.join("registry/src/dummy-0.1.0/lib.rs");
1292+
assert_eq!(
1293+
found.unwrap(),
1294+
expected,
1295+
"pub(crate) struct should be located"
1296+
);
1297+
}
11911298

1192-
env::set_var("CARGO_HOME", &cargo_home);
1299+
/// Regression for issue #82: derive attributes preceding the
1300+
/// declaration must not prevent discovery.
1301+
#[test]
1302+
#[serial]
1303+
fn test_find_external_type_path_with_derive_attributes() {
1304+
let (reg, analyzer) = FakeRegistry::new(&[(
1305+
"lib.rs",
1306+
"#[derive(Debug, Clone)]\npub struct WithDerives { field: i32 }",
1307+
)]);
1308+
let found = analyzer.find_external_type_path("WithDerives");
1309+
let expected = reg.cargo_home.join("registry/src/dummy-0.1.0/lib.rs");
1310+
assert_eq!(
1311+
found.unwrap(),
1312+
expected,
1313+
"#[derive(...)] pub struct should be located"
1314+
);
1315+
}
1316+
1317+
/// Regression for issue #82: generic parameters must not prevent
1318+
/// discovery.
1319+
#[test]
1320+
#[serial]
1321+
fn test_find_external_type_path_with_generics() {
1322+
let (reg, analyzer) =
1323+
FakeRegistry::new(&[("lib.rs", "pub struct Generic<T, U> { a: T, b: U }")]);
1324+
let found = analyzer.find_external_type_path("Generic");
1325+
let expected = reg.cargo_home.join("registry/src/dummy-0.1.0/lib.rs");
1326+
assert_eq!(
1327+
found.unwrap(),
1328+
expected,
1329+
"generic pub struct should be located"
1330+
);
1331+
}
11931332

1194-
let analyzer: CommandAnalyzer = CommandAnalyzer::default();
1195-
let found: Option<PathBuf> = analyzer.find_external_type_path("ExternalFoo");
1333+
/// Multi-line declarations (keyword and identifier on different lines)
1334+
/// must be discovered — the old `contains("struct X")` heuristic missed
1335+
/// these.
1336+
#[test]
1337+
#[serial]
1338+
fn test_find_external_type_path_multiline() {
1339+
let (reg, analyzer) =
1340+
FakeRegistry::new(&[("lib.rs", "pub\n struct\n Multiline\n{\n x: i32,\n }")]);
1341+
let found = analyzer.find_external_type_path("Multiline");
1342+
let expected = reg.cargo_home.join("registry/src/dummy-0.1.0/lib.rs");
1343+
assert_eq!(
1344+
found.unwrap(),
1345+
expected,
1346+
"multi-line struct should be located"
1347+
);
1348+
}
1349+
1350+
/// Enums with attributes and visibility must be discovered too.
1351+
#[test]
1352+
#[serial]
1353+
fn test_find_external_type_path_enum_with_attributes() {
1354+
let (reg, analyzer) =
1355+
FakeRegistry::new(&[("lib.rs", "#[derive(Debug)]\npub enum EnumAttr { A, B }")]);
1356+
let found = analyzer.find_external_type_path("EnumAttr");
1357+
let expected = reg.cargo_home.join("registry/src/dummy-0.1.0/lib.rs");
1358+
assert_eq!(
1359+
found.unwrap(),
1360+
expected,
1361+
"pub enum with derive should be located"
1362+
);
1363+
}
11961364

1365+
/// Types declared inside an inline `mod` block should still be found.
1366+
#[test]
1367+
#[serial]
1368+
fn test_find_external_type_path_nested_module() {
1369+
let (reg, analyzer) =
1370+
FakeRegistry::new(&[("lib.rs", "mod inner {\n pub struct Nested;\n}\n")]);
1371+
let found = analyzer.find_external_type_path("Nested");
1372+
let expected = reg.cargo_home.join("registry/src/dummy-0.1.0/lib.rs");
11971373
assert_eq!(
11981374
found.unwrap(),
1199-
file_path,
1200-
"the external‑type lookup should locate the dummy `ExternalFoo`"
1375+
expected,
1376+
"struct inside an inline mod should be located"
1377+
);
1378+
}
1379+
1380+
/// The lookup must not return false positives: a struct whose name only
1381+
/// *starts with* the searched identifier (e.g. `ExternalFooBar` when
1382+
/// searching for `ExternalFoo`) must not match. The old substring
1383+
/// heuristic would incorrectly match this.
1384+
#[test]
1385+
#[serial]
1386+
fn test_find_external_type_path_no_false_positive_prefix() {
1387+
let (_reg, analyzer) = FakeRegistry::new(&[("lib.rs", "pub struct ExternalFooBar;")]);
1388+
let found = analyzer.find_external_type_path("ExternalFoo");
1389+
assert!(
1390+
found.is_none(),
1391+
"a prefix-named struct must not match the shorter identifier"
1392+
);
1393+
}
1394+
1395+
/// An absent type must resolve to `None`.
1396+
#[test]
1397+
#[serial]
1398+
fn test_find_external_type_path_missing() {
1399+
let (_reg, analyzer) = FakeRegistry::new(&[("lib.rs", "pub struct SomethingElse;")]);
1400+
let found = analyzer.find_external_type_path("ExternalFoo");
1401+
assert!(found.is_none(), "a missing type must resolve to None");
1402+
}
1403+
1404+
/// A file that fails to parse must be skipped, not panic.
1405+
#[test]
1406+
#[serial]
1407+
fn test_find_external_type_path_skips_unparseable_file() {
1408+
let (_reg, analyzer) = FakeRegistry::new(&[("lib.rs", "this is not valid rust !!!")]);
1409+
let found = analyzer.find_external_type_path("ExternalFoo");
1410+
assert!(
1411+
found.is_none(),
1412+
"an unparseable file must be skipped without panicking"
12011413
);
12021414
}
12031415
}

0 commit comments

Comments
 (0)