1- use std:: collections:: HashSet ;
1+ use std:: collections:: { HashMap , HashSet } ;
22
33use nix_uri:: urls:: UrlWrapper ;
44use nix_uri:: { FlakeRef , NixUriResult } ;
@@ -85,6 +85,28 @@ fn is_follows_reference_to_parent(url: &str, parent: &str) -> bool {
8585 url_trimmed. starts_with ( & format ! ( "{}/" , parent) )
8686}
8787
88+ /// Collect nested follows paths already declared in flake.nix.
89+ fn collect_existing_follows ( inputs : & InputMap ) -> HashSet < String > {
90+ let mut existing = HashSet :: new ( ) ;
91+ for ( input_id, input) in inputs {
92+ for follows in input. follows ( ) {
93+ if let Follows :: Indirect ( nested_name, _target) = follows {
94+ existing. insert ( format ! ( "{}.{}" , input_id, nested_name) ) ;
95+ }
96+ }
97+ }
98+ existing
99+ }
100+
101+ /// Convert a lockfile follows path like "parent.child" to flake follows syntax "parent/child".
102+ fn lock_follows_to_flake_target ( target : & str ) -> String {
103+ if target. contains ( '.' ) {
104+ target. replace ( '.' , "/" )
105+ } else {
106+ target. to_string ( )
107+ }
108+ }
109+
88110/// Load nested inputs from lockfile and top-level inputs from flake.nix.
89111fn load_follow_context (
90112 flake_edit : & mut FlakeEdit ,
@@ -868,6 +890,8 @@ fn collect_stale_follows(
868890/// The config file controls behavior:
869891/// - `follow.ignore`: List of input names to skip
870892/// - `follow.aliases`: Map of canonical names to alternatives (e.g., nixpkgs = ["nixpkgs-lib"])
893+ /// - `follow.transitive_min`: Minimum number of matching transitive follows before adding a
894+ /// top-level follows input (set to 0 to disable)
871895pub fn follow_auto ( editor : & Editor , flake_edit : & mut FlakeEdit , state : & AppState ) -> Result < ( ) > {
872896 follow_auto_impl ( editor, flake_edit, state, false )
873897}
@@ -893,12 +917,14 @@ fn follow_auto_impl(
893917 let to_unfollow = collect_stale_follows ( & ctx. inputs , & existing_nested_paths) ;
894918
895919 let follow_config = & state. config . follow ;
920+ let existing_follows = collect_existing_follows ( & ctx. inputs ) ;
921+ let transitive_min = follow_config. transitive_min ( ) ;
922+ let mut seen_nested: HashSet < String > = HashSet :: new ( ) ;
896923
897924 // Collect candidates: nested inputs that match a top-level input
898- let to_follow: Vec < ( String , String ) > = ctx
925+ let mut to_follow: Vec < ( String , String ) > = ctx
899926 . nested_inputs
900927 . iter ( )
901- . filter ( |nested| nested. follows . is_none ( ) )
902928 . filter_map ( |nested| {
903929 let ( parent, nested_name) =
904930 split_quoted_path ( & nested. path ) . unwrap_or ( ( & nested. path , & nested. path ) ) ;
@@ -909,6 +935,12 @@ fn follow_auto_impl(
909935 return None ;
910936 }
911937
938+ // Skip if already configured in flake.nix
939+ if existing_follows. contains ( & nested. path ) {
940+ tracing:: debug!( "Skipping {}: already follows in flake.nix" , nested. path) ;
941+ return None ;
942+ }
943+
912944 // Find matching top-level input (direct match or via alias)
913945 let matching_top_level = ctx
914946 . top_level_inputs
@@ -936,7 +968,200 @@ fn follow_auto_impl(
936968 } )
937969 . collect ( ) ;
938970
939- if to_follow. is_empty ( ) && to_unfollow. is_empty ( ) {
971+ for ( nested_path, _target) in & to_follow {
972+ seen_nested. insert ( nested_path. clone ( ) ) ;
973+ }
974+
975+ let mut transitive_groups: HashMap < String , HashMap < String , Vec < String > > > = HashMap :: new ( ) ;
976+
977+ if transitive_min > 0 {
978+ for nested in ctx. nested_inputs . iter ( ) {
979+ let nested_name = nested. path . split ( '.' ) . next_back ( ) . unwrap_or ( & nested. path ) ;
980+ let parent = nested. path . split ( '.' ) . next ( ) . unwrap_or ( & nested. path ) ;
981+
982+ if follow_config. is_ignored ( & nested. path , nested_name) {
983+ continue ;
984+ }
985+
986+ if existing_follows. contains ( & nested. path ) || seen_nested. contains ( & nested. path ) {
987+ continue ;
988+ }
989+
990+ let matching_top_level = ctx
991+ . top_level_inputs
992+ . iter ( )
993+ . find ( |top| follow_config. can_follow ( nested_name, top) ) ;
994+
995+ if matching_top_level. is_some ( ) {
996+ continue ;
997+ }
998+
999+ let Some ( transitive_target) = nested. follows . as_ref ( ) else {
1000+ continue ;
1001+ } ;
1002+
1003+ // Only consider transitive follows (path with a parent segment).
1004+ if !transitive_target. contains ( '.' ) {
1005+ continue ;
1006+ }
1007+
1008+ // Avoid self-follow situations.
1009+ if transitive_target == nested_name {
1010+ continue ;
1011+ }
1012+
1013+ let top_level_name = follow_config
1014+ . resolve_alias ( nested_name)
1015+ . unwrap_or ( nested_name)
1016+ . to_string ( ) ;
1017+
1018+ // Skip if a top-level input already exists with that name.
1019+ if ctx. top_level_inputs . contains ( & top_level_name) {
1020+ continue ;
1021+ }
1022+
1023+ // Skip if target already follows from parent (would create cycle)
1024+ if let Some ( target_input) = ctx. inputs . get ( transitive_target. as_str ( ) )
1025+ && is_follows_reference_to_parent ( target_input. url ( ) , parent)
1026+ {
1027+ continue ;
1028+ }
1029+
1030+ transitive_groups
1031+ . entry ( top_level_name)
1032+ . or_default ( )
1033+ . entry ( transitive_target. clone ( ) )
1034+ . or_default ( )
1035+ . push ( nested. path . clone ( ) ) ;
1036+ }
1037+ }
1038+
1039+ // Pass 2b: Group Direct references (follows: None) by canonical name.
1040+ // These are nested inputs that point to separate lock nodes rather than
1041+ // following an existing path. When multiple parents share the same
1042+ // dependency (e.g., treefmt.nixpkgs and treefmt-nix.nixpkgs), we can
1043+ // promote one to top-level and have the others follow it.
1044+ // Each entry: canonical_name -> Vec<(path, url)>
1045+ let mut direct_groups: HashMap < String , Vec < ( String , Option < String > ) > > = HashMap :: new ( ) ;
1046+
1047+ if transitive_min > 0 {
1048+ for nested in ctx. nested_inputs . iter ( ) {
1049+ let nested_name = nested. path . split ( '.' ) . next_back ( ) . unwrap_or ( & nested. path ) ;
1050+
1051+ if nested. follows . is_some ( ) {
1052+ continue ;
1053+ }
1054+
1055+ if follow_config. is_ignored ( & nested. path , nested_name) {
1056+ continue ;
1057+ }
1058+
1059+ if existing_follows. contains ( & nested. path ) || seen_nested. contains ( & nested. path ) {
1060+ continue ;
1061+ }
1062+
1063+ let matching_top_level = ctx
1064+ . top_level_inputs
1065+ . iter ( )
1066+ . find ( |top| follow_config. can_follow ( nested_name, top) ) ;
1067+
1068+ if matching_top_level. is_some ( ) {
1069+ continue ;
1070+ }
1071+
1072+ let canonical_name = follow_config
1073+ . resolve_alias ( nested_name)
1074+ . unwrap_or ( nested_name)
1075+ . to_string ( ) ;
1076+
1077+ if ctx. top_level_inputs . contains ( & canonical_name) {
1078+ continue ;
1079+ }
1080+
1081+ direct_groups
1082+ . entry ( canonical_name)
1083+ . or_default ( )
1084+ . push ( ( nested. path . clone ( ) , nested. url . clone ( ) ) ) ;
1085+ }
1086+ }
1087+
1088+ let mut toplevel_follows: Vec < ( String , String ) > = Vec :: new ( ) ;
1089+ let mut toplevel_adds: Vec < ( String , String ) > = Vec :: new ( ) ;
1090+
1091+ if transitive_min > 0 {
1092+ for ( top_name, targets) in transitive_groups {
1093+ let mut eligible: Vec < ( String , Vec < String > ) > = targets
1094+ . into_iter ( )
1095+ . filter ( |( _, paths) | paths. len ( ) >= transitive_min)
1096+ . collect ( ) ;
1097+
1098+ if eligible. len ( ) != 1 {
1099+ continue ;
1100+ }
1101+
1102+ let ( target_path, paths) = eligible. pop ( ) . unwrap ( ) ;
1103+ let follow_target = lock_follows_to_flake_target ( & target_path) ;
1104+
1105+ if follow_target == top_name {
1106+ continue ;
1107+ }
1108+
1109+ toplevel_follows. push ( ( top_name. clone ( ) , follow_target) ) ;
1110+
1111+ for path in paths {
1112+ if seen_nested. insert ( path. clone ( ) ) {
1113+ to_follow. push ( ( path, top_name. clone ( ) ) ) ;
1114+ }
1115+ }
1116+ }
1117+
1118+ // Promote Direct reference groups: add a new top-level input with the
1119+ // URL from one of the nested references, then have all paths follow it.
1120+ // Only promote if at least one follows can actually be applied.
1121+ let mut direct_groups_sorted: Vec < _ > = direct_groups. into_iter ( ) . collect ( ) ;
1122+ direct_groups_sorted. sort_by ( |a, b| a. 0 . cmp ( & b. 0 ) ) ;
1123+ for ( canonical_name, mut entries) in direct_groups_sorted {
1124+ if entries. len ( ) < transitive_min {
1125+ continue ;
1126+ }
1127+
1128+ entries. sort_by ( |a, b| a. 0 . cmp ( & b. 0 ) ) ;
1129+
1130+ let url = entries. iter ( ) . find_map ( |( _, u) | u. clone ( ) ) ;
1131+ let Some ( url) = url else {
1132+ continue ;
1133+ } ;
1134+
1135+ // Dry-run: check that at least one follows can be applied.
1136+ let can_follow = entries. iter ( ) . any ( |( path, _) | {
1137+ let change = Change :: Follows {
1138+ input : path. clone ( ) . into ( ) ,
1139+ target : canonical_name. clone ( ) ,
1140+ } ;
1141+ FlakeEdit :: from_text ( & editor. text ( ) )
1142+ . ok ( )
1143+ . and_then ( |mut fe| fe. apply_change ( change) . ok ( ) . flatten ( ) )
1144+ . is_some ( )
1145+ } ) ;
1146+ if !can_follow {
1147+ continue ;
1148+ }
1149+
1150+ toplevel_adds. push ( ( canonical_name. clone ( ) , url) ) ;
1151+
1152+ for ( path, _) in & entries {
1153+ if seen_nested. insert ( path. clone ( ) ) {
1154+ to_follow. push ( ( path. clone ( ) , canonical_name. clone ( ) ) ) ;
1155+ }
1156+ }
1157+ }
1158+ }
1159+
1160+ if to_follow. is_empty ( )
1161+ && to_unfollow. is_empty ( )
1162+ && toplevel_follows. is_empty ( )
1163+ && toplevel_adds. is_empty ( )
1164+ {
9401165 if !quiet {
9411166 println ! ( "All inputs are already deduplicated." ) ;
9421167 }
@@ -947,7 +1172,39 @@ fn follow_auto_impl(
9471172 let mut current_text = editor. text ( ) ;
9481173 let mut applied: Vec < ( & str , & str ) > = Vec :: new ( ) ;
9491174
950- for ( input_path, target) in & to_follow {
1175+ // First, add new top-level inputs (from Direct reference promotion).
1176+ // These must be added before follows declarations that reference them.
1177+ for ( id, url) in & toplevel_adds {
1178+ let change = Change :: Add {
1179+ id : Some ( id. clone ( ) ) ,
1180+ uri : Some ( url. clone ( ) ) ,
1181+ flake : true ,
1182+ } ;
1183+
1184+ let mut temp_flake_edit =
1185+ FlakeEdit :: from_text ( & current_text) . map_err ( CommandError :: FlakeEdit ) ?;
1186+
1187+ match temp_flake_edit. apply_change ( change) {
1188+ Ok ( Some ( resulting_text) ) => {
1189+ let validation = validate:: validate ( & resulting_text) ;
1190+ if validation. is_ok ( ) {
1191+ current_text = resulting_text;
1192+ } else {
1193+ for err in validation. errors {
1194+ eprintln ! ( "Error adding top-level input {}: {}" , id, err) ;
1195+ }
1196+ }
1197+ }
1198+ Ok ( None ) => eprintln ! ( "Could not add top-level input {}" , id) ,
1199+ Err ( e) => eprintln ! ( "Error adding top-level input {}: {}" , id, e) ,
1200+ }
1201+ }
1202+
1203+ let mut follow_changes: Vec < ( String , String ) > = Vec :: new ( ) ;
1204+ follow_changes. extend ( toplevel_follows. into_iter ( ) ) ;
1205+ follow_changes. extend ( to_follow. into_iter ( ) ) ;
1206+
1207+ for ( input_path, target) in & follow_changes {
9511208 let change = Change :: Follows {
9521209 input : input_path. clone ( ) . into ( ) ,
9531210 target : target. clone ( ) ,
@@ -1023,9 +1280,13 @@ fn follow_auto_impl(
10231280 }
10241281 ) ;
10251282 for ( input_path, target) in & applied {
1026- let ( parent, nested_name) =
1027- split_quoted_path ( input_path) . unwrap_or ( ( input_path, input_path) ) ;
1028- println ! ( " {}.{} → {}" , parent, nested_name, target) ;
1283+ if input_path. contains ( '.' ) {
1284+ let ( parent, nested_name) =
1285+ split_quoted_path ( input_path) . unwrap_or ( ( input_path, input_path) ) ;
1286+ println ! ( " {}.{} → {}" , parent, nested_name, target) ;
1287+ } else {
1288+ println ! ( " {} → {}" , input_path, target) ;
1289+ }
10291290 }
10301291 }
10311292
0 commit comments