@@ -173,6 +173,45 @@ pub fn get_bun_global_prefix() -> Option<String> {
173173 )
174174}
175175
176+ // ---------------------------------------------------------------------------
177+ // Helpers: synchronous wildcard directory resolver
178+ // ---------------------------------------------------------------------------
179+
180+ /// Resolve a path with `"*"` wildcard segments synchronously.
181+ ///
182+ /// Each segment is either a literal directory name or `"*"` which matches any
183+ /// directory entry. Symlinks are followed via `std::fs::metadata`.
184+ fn find_node_dirs_sync ( base : & Path , segments : & [ & str ] ) -> Vec < PathBuf > {
185+ if !base. is_dir ( ) {
186+ return Vec :: new ( ) ;
187+ }
188+ if segments. is_empty ( ) {
189+ return vec ! [ base. to_path_buf( ) ] ;
190+ }
191+
192+ let first = segments[ 0 ] ;
193+ let rest = & segments[ 1 ..] ;
194+
195+ if first == "*" {
196+ let mut results = Vec :: new ( ) ;
197+ if let Ok ( entries) = std:: fs:: read_dir ( base) {
198+ for entry in entries. flatten ( ) {
199+ // Follow symlinks: use metadata() not symlink_metadata()
200+ let is_dir = entry
201+ . metadata ( )
202+ . map ( |m| m. is_dir ( ) )
203+ . unwrap_or ( false ) ;
204+ if is_dir {
205+ results. extend ( find_node_dirs_sync ( & base. join ( entry. file_name ( ) ) , rest) ) ;
206+ }
207+ }
208+ }
209+ results
210+ } else {
211+ find_node_dirs_sync ( & base. join ( first) , rest)
212+ }
213+ }
214+
176215// ---------------------------------------------------------------------------
177216// NpmCrawler
178217// ---------------------------------------------------------------------------
@@ -297,19 +336,60 @@ impl NpmCrawler {
297336
298337 /// Collect global `node_modules` paths from all known package managers.
299338 fn get_global_node_modules_paths ( & self ) -> Vec < PathBuf > {
339+ let mut seen = HashSet :: new ( ) ;
300340 let mut paths = Vec :: new ( ) ;
301341
342+ let mut add = |p : PathBuf | {
343+ if p. is_dir ( ) && seen. insert ( p. clone ( ) ) {
344+ paths. push ( p) ;
345+ }
346+ } ;
347+
302348 if let Ok ( npm_path) = get_npm_global_prefix ( ) {
303- paths . push ( PathBuf :: from ( npm_path) ) ;
349+ add ( PathBuf :: from ( npm_path) ) ;
304350 }
305351 if let Some ( pnpm_path) = get_pnpm_global_prefix ( ) {
306- paths . push ( PathBuf :: from ( pnpm_path) ) ;
352+ add ( PathBuf :: from ( pnpm_path) ) ;
307353 }
308354 if let Some ( yarn_path) = get_yarn_global_prefix ( ) {
309- paths . push ( PathBuf :: from ( yarn_path) ) ;
355+ add ( PathBuf :: from ( yarn_path) ) ;
310356 }
311357 if let Some ( bun_path) = get_bun_global_prefix ( ) {
312- paths. push ( PathBuf :: from ( bun_path) ) ;
358+ add ( PathBuf :: from ( bun_path) ) ;
359+ }
360+
361+ // macOS-specific fallback paths
362+ if cfg ! ( target_os = "macos" ) {
363+ let home = std:: env:: var ( "HOME" ) . unwrap_or_default ( ) ;
364+
365+ // Homebrew Apple Silicon
366+ add ( PathBuf :: from ( "/opt/homebrew/lib/node_modules" ) ) ;
367+ // Homebrew Intel / default npm
368+ add ( PathBuf :: from ( "/usr/local/lib/node_modules" ) ) ;
369+
370+ if !home. is_empty ( ) {
371+ // nvm
372+ for p in find_node_dirs_sync (
373+ & PathBuf :: from ( & home) . join ( ".nvm/versions/node" ) ,
374+ & [ "*" , "lib" , "node_modules" ] ,
375+ ) {
376+ add ( p) ;
377+ }
378+ // volta
379+ for p in find_node_dirs_sync (
380+ & PathBuf :: from ( & home) . join ( ".volta/tools/image/node" ) ,
381+ & [ "*" , "lib" , "node_modules" ] ,
382+ ) {
383+ add ( p) ;
384+ }
385+ // fnm
386+ for p in find_node_dirs_sync (
387+ & PathBuf :: from ( & home) . join ( ".fnm/node-versions" ) ,
388+ & [ "*" , "installation" , "lib" , "node_modules" ] ,
389+ ) {
390+ add ( p) ;
391+ }
392+ }
313393 }
314394
315395 paths
@@ -790,6 +870,48 @@ mod tests {
790870 assert_eq ! ( packages[ 0 ] . purl, "pkg:npm/@types/node@20.0.0" ) ;
791871 }
792872
873+ #[ test]
874+ fn test_find_node_dirs_sync_wildcard ( ) {
875+ // Create an nvm-like layout: base/v18.0.0/lib/node_modules
876+ let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
877+ let nm1 = dir. path ( ) . join ( "v18.0.0/lib/node_modules" ) ;
878+ let nm2 = dir. path ( ) . join ( "v20.1.0/lib/node_modules" ) ;
879+ std:: fs:: create_dir_all ( & nm1) . unwrap ( ) ;
880+ std:: fs:: create_dir_all ( & nm2) . unwrap ( ) ;
881+
882+ let results = find_node_dirs_sync ( dir. path ( ) , & [ "*" , "lib" , "node_modules" ] ) ;
883+ assert_eq ! ( results. len( ) , 2 ) ;
884+ assert ! ( results. contains( & nm1) ) ;
885+ assert ! ( results. contains( & nm2) ) ;
886+ }
887+
888+ #[ test]
889+ fn test_find_node_dirs_sync_empty ( ) {
890+ // Non-existent base path should return empty
891+ let results = find_node_dirs_sync ( Path :: new ( "/nonexistent/path/xyz" ) , & [ "*" , "lib" ] ) ;
892+ assert ! ( results. is_empty( ) ) ;
893+ }
894+
895+ #[ test]
896+ fn test_find_node_dirs_sync_literal ( ) {
897+ // All literal segments (no wildcard)
898+ let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
899+ let target = dir. path ( ) . join ( "lib/node_modules" ) ;
900+ std:: fs:: create_dir_all ( & target) . unwrap ( ) ;
901+
902+ let results = find_node_dirs_sync ( dir. path ( ) , & [ "lib" , "node_modules" ] ) ;
903+ assert_eq ! ( results. len( ) , 1 ) ;
904+ assert_eq ! ( results[ 0 ] , target) ;
905+ }
906+
907+ #[ cfg( target_os = "macos" ) ]
908+ #[ test]
909+ fn test_macos_get_global_node_modules_paths_no_panic ( ) {
910+ let crawler = NpmCrawler :: new ( ) ;
911+ // Should not panic, even if no package managers are installed
912+ let _paths = crawler. get_global_node_modules_paths ( ) ;
913+ }
914+
793915 #[ tokio:: test]
794916 async fn test_find_by_purls ( ) {
795917 let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
0 commit comments