1212
1313use crate :: domain:: { Candidate , Toolchain } ;
1414use crate :: eb_parse:: { resolve_easyconfig_file, resolve_easyconfig_str} ;
15- use crate :: hierarchy:: { hierarchy_for_with_tree, ToolchainHierarchy } ;
15+ use crate :: hierarchy:: {
16+ filter_candidates_in_hierarchy, hierarchy_for_with_tree, ToolchainHierarchy ,
17+ } ;
1618use crate :: package:: { OutputRequest , PackageOrigin , PackagePlan , ProfileEnvironment , StackPolicy } ;
1719use crate :: package_catalog:: {
1820 CatalogProviderKind , PackageCatalogError , PackageSourceCatalog , PackageSourceProvider ,
@@ -33,6 +35,7 @@ use crate::package_workflow::{
3335 write_package_bundle_into, BumpPackageRequest , NewPackageRequest , PackageBundle ,
3436 PackageWorkflowError , WrittenPackageBundle ,
3537} ;
38+ use crate :: provides:: existing_language_provider;
3639use crate :: version:: matches_req;
3740use serde:: { Deserialize , Serialize } ;
3841use serde_json:: { json, Map , Value } ;
@@ -315,11 +318,7 @@ pub fn plan_package_closure_with_sources(
315318 ) ?;
316319 let target_hierarchy = hierarchy_for_with_tree ( & request. toolchain , None , & tree. candidates ) . ok ( ) ;
317320
318- let ( mut plan, sbom) = prepare_new_package_plan ( request) ?;
319- // The index answers "what version is this leftover", so it has to reach the
320- // root plan here too: the closure path builds its own requests and the CLI
321- // goes through this one, not through plan_new_package.
322- plan. package_index = request. package_index . clone ( ) ;
321+ let ( plan, sbom) = prepare_new_package_plan ( request) ?;
323322 let mut state = ClosureState {
324323 robot : tree. candidates ,
325324 generated : HashMap :: new ( ) ,
@@ -654,6 +653,13 @@ impl ClosureState<'_> {
654653 stack_policy : & StackPolicy ,
655654 path : & [ String ] ,
656655 ) -> Result < PackageBundle , PackageClosureError > {
656+ // A language root already on the robot must not hole-fill leftovers
657+ // as companions. complete_package_bundle short-circuits to the same
658+ // already-provided residual as plan_new_package.
659+ if language_root_already_provided ( prepared. plan ( ) , & self . universe ( ) ) {
660+ return self . complete_prepared ( prepared, stack_policy) ;
661+ }
662+
657663 // Fill holes for every requested profile before the final multi-profile solve.
658664 let profiles: Vec < String > = prepared
659665 . plan ( )
@@ -664,7 +670,14 @@ impl ClosureState<'_> {
664670 for profile in & profiles {
665671 self . fill_holes_for_profile ( prepared. plan ( ) , profile, stack_policy, path) ?;
666672 }
673+ self . complete_prepared ( prepared, stack_policy)
674+ }
667675
676+ fn complete_prepared (
677+ & self ,
678+ prepared : PreparedCompanion ,
679+ stack_policy : & StackPolicy ,
680+ ) -> Result < PackageBundle , PackageClosureError > {
668681 let candidates = self . universe ( ) ;
669682 match prepared {
670683 PreparedCompanion :: Foreign { plan, sbom } => {
@@ -750,12 +763,10 @@ impl ClosureState<'_> {
750763 hole : & UnsatisfiedDirectDependency ,
751764 path : & [ String ] ,
752765 ) -> Result < ( ) , PackageClosureError > {
753- if path
754- . iter ( )
755- . any ( |step| package_identity ( step) == package_identity ( & hole. name ) )
756- {
766+ let step = hole_path_step ( hole) ;
767+ if path. iter ( ) . any ( |seen| seen == & step) {
757768 let mut cycle = path. to_vec ( ) ;
758- cycle. push ( hole . name . clone ( ) ) ;
769+ cycle. push ( step ) ;
759770 return Err ( PackageClosureError :: Cycle { path : cycle } ) ;
760771 }
761772
@@ -786,13 +797,14 @@ impl ClosureState<'_> {
786797 }
787798 }
788799
789- let key = companion_key ( & provider) ;
800+ let key = companion_key ( & provider, hole ) ;
790801 if self . generated . contains_key ( & key) {
791802 // Already closed; verify the generated candidate still satisfies the hole.
792803 let entry = self . generated . get ( & key) . expect ( "just checked" ) ;
793804 let ok = entry. candidates . iter ( ) . any ( |candidate| {
794805 package_identity ( & candidate. name ) == package_identity ( & hole. name )
795806 && matches_req ( & candidate. version , & hole. version_req )
807+ && hole. matches_versionsuffix ( candidate. versionsuffix . as_deref ( ) )
796808 } ) ;
797809 if ok {
798810 return Ok ( ( ) ) ;
@@ -983,6 +995,7 @@ fn resolve_provider_candidates_for_hole(
983995 . providers ( )
984996 . iter ( )
985997 . filter ( |provider| package_identity ( & provider. name ) == package_identity ( & hole. name ) )
998+ . filter ( |provider| catalog_provider_matches_hole_suffix ( provider, hole) )
986999 . collect :: < Vec < _ > > ( ) ;
9871000 if !named. is_empty ( ) {
9881001 let compatible = named
@@ -995,7 +1008,24 @@ fn resolve_provider_candidates_for_hole(
9951008 . is_none_or ( |version| matches_req ( version, & hole. version_req ) )
9961009 } )
9971010 . collect :: < Vec < _ > > ( ) ;
998- return match compatible. as_slice ( ) {
1011+ // Unversioned wildcards must not veto a unique versioned sibling.
1012+ let versioned = compatible
1013+ . iter ( )
1014+ . copied ( )
1015+ . filter ( |provider| provider. version . is_some ( ) )
1016+ . collect :: < Vec < _ > > ( ) ;
1017+ let preferred = match versioned. as_slice ( ) {
1018+ [ _] => versioned,
1019+ [ ] => compatible,
1020+ many => {
1021+ return Err ( PackageClosureError :: AmbiguousProvider {
1022+ name : hole. name . clone ( ) ,
1023+ version : format ! ( " ({})" , hole. version_req) ,
1024+ count : many. len ( ) ,
1025+ } ) ;
1026+ }
1027+ } ;
1028+ return match preferred. as_slice ( ) {
9991029 [ provider] => Ok ( vec ! [ ( * provider) . clone( ) ] ) ,
10001030 [ ] => Err ( PackageClosureError :: IncompatibleProviderVersion {
10011031 name : hole. name . clone ( ) ,
@@ -1246,15 +1276,83 @@ fn candidates_from_bundle(bundle: &PackageBundle) -> Result<Vec<Candidate>, Pack
12461276 Ok ( candidates)
12471277}
12481278
1249- fn companion_key ( provider : & PackageSourceProvider ) -> String {
1279+ fn language_root_already_provided ( plan : & PackagePlan , candidates : & [ Candidate ] ) -> bool {
1280+ if !matches ! ( plan. origin, PackageOrigin :: Pypi | PackageOrigin :: Cran ) {
1281+ return false ;
1282+ }
1283+ let admitted = match hierarchy_for_with_tree ( & plan. build . toolchain , None , candidates) {
1284+ Ok ( hierarchy) => filter_candidates_in_hierarchy ( candidates, & hierarchy) ,
1285+ // Unknown hierarchy is not "provided on this generation".
1286+ Err ( _) => return false ,
1287+ } ;
1288+ existing_language_provider ( & plan. package . name , & admitted) . is_some ( )
1289+ }
1290+
1291+ /// The identity a hole contributes to the closure path: its package, with
1292+ /// the versionsuffix it asks for, so `-MPI` and `-CUDA` builds are two steps.
1293+ fn hole_path_step ( hole : & UnsatisfiedDirectDependency ) -> String {
1294+ let identity = package_identity ( & hole. name ) ;
1295+ match hole
1296+ . versionsuffix
1297+ . as_deref ( )
1298+ . filter ( |suffix| !suffix. is_empty ( ) )
1299+ {
1300+ Some ( suffix) => format ! ( "{identity}{suffix}" ) ,
1301+ None => identity,
1302+ }
1303+ }
1304+
1305+ fn catalog_provider_matches_hole_suffix (
1306+ provider : & PackageSourceProvider ,
1307+ hole : & UnsatisfiedDirectDependency ,
1308+ ) -> bool {
1309+ if hole
1310+ . versionsuffix
1311+ . as_deref ( )
1312+ . is_none_or ( |suffix| suffix. is_empty ( ) )
1313+ {
1314+ return true ;
1315+ }
1316+ if provider. provider != CatalogProviderKind :: EasyBuildBump {
1317+ return false ;
1318+ }
1319+ let suffix = resolve_easyconfig_file ( & provider. source )
1320+ . ok ( )
1321+ . and_then ( |resolved| resolved. versionsuffix ) ;
1322+ hole. matches_versionsuffix ( suffix. as_deref ( ) )
1323+ }
1324+
1325+ fn companion_key ( provider : & PackageSourceProvider , hole : & UnsatisfiedDirectDependency ) -> String {
12501326 format ! (
1251- "{}@{}@{}" ,
1327+ "{}@{}@{}@{} " ,
12521328 package_identity( & provider. name) ,
12531329 provider. version. as_deref( ) . unwrap_or( "" ) ,
1254- provider. toolchain. label( )
1330+ provider. toolchain. label( ) ,
1331+ companion_versionsuffix( provider, hole)
12551332 )
12561333}
12571334
1335+ fn companion_versionsuffix (
1336+ provider : & PackageSourceProvider ,
1337+ hole : & UnsatisfiedDirectDependency ,
1338+ ) -> String {
1339+ if let Some ( suffix) = hole
1340+ . versionsuffix
1341+ . as_deref ( )
1342+ . filter ( |suffix| !suffix. is_empty ( ) )
1343+ {
1344+ return suffix. to_string ( ) ;
1345+ }
1346+ if provider. provider != CatalogProviderKind :: EasyBuildBump {
1347+ return String :: new ( ) ;
1348+ }
1349+ resolve_easyconfig_file ( & provider. source )
1350+ . ok ( )
1351+ . and_then ( |resolved| resolved. versionsuffix )
1352+ . filter ( |suffix| !suffix. is_empty ( ) )
1353+ . unwrap_or_default ( )
1354+ }
1355+
12581356fn package_identity ( name : & str ) -> String {
12591357 name. chars ( )
12601358 . filter ( |character| character. is_ascii_alphanumeric ( ) )
0 commit comments