Skip to content

Commit daa2c7a

Browse files
authored
Merge pull request #440 from a-kenji/ke-follow-add-transitive
follows: Add transitive dependencies to the top-level
2 parents 99abdbc + f700ec7 commit daa2c7a

21 files changed

+744
-58
lines changed

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,10 @@ Run `flake-edit config --print-default` to create a default configuration:
387387
# - Simple name: "systems" - ignores all nested inputs with that name
388388
# ignore = ["systems", "crane.flake-utils"]
389389
390+
# Minimum number of transitive follows required to add a top-level follows input.
391+
# Set to 0 to disable transitive follows deduplication.
392+
# transitive_min = 2
393+
390394
# Alias mappings.
391395
# Key is the canonical name (must exist at top-level), values are alternatives.
392396
# Example: if nested input is "nixpkgs-lib" and top-level "nixpkgs" exists,

src/app/commands.rs

Lines changed: 269 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use std::collections::HashSet;
1+
use std::collections::{HashMap, HashSet};
22

33
use nix_uri::urls::UrlWrapper;
44
use 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.
89111
fn 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)
871895
pub 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

src/assets/config.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88
# - Simple name: "systems" - ignores all nested inputs with that name
99
# ignore = ["systems", "crane.flake-utils"]
1010

11+
# Minimum number of transitive follows required to add a top-level follows input.
12+
# Set to 0 to disable transitive follows deduplication.
13+
# transitive_min = 2
14+
1115
# Alias mappings.
1216
# Key is the canonical name (must exist at top-level), values are alternatives.
1317
# Example: if nested input is "nixpkgs-lib" and top-level "nixpkgs" exists,

src/change.rs

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -182,12 +182,21 @@ impl Change {
182182
)]
183183
}
184184
Change::Follows { input, target } => {
185-
vec![format!(
186-
"Added follows: {}.inputs.{}.follows = \"{}\"",
187-
input.input(),
188-
input.follows().unwrap_or("?"),
189-
target
190-
)]
185+
let msg = if let Some(nested) = input.follows() {
186+
format!(
187+
"Added follows: {}.inputs.{}.follows = \"{}\"",
188+
input.input(),
189+
nested,
190+
target
191+
)
192+
} else {
193+
format!(
194+
"Added follows: inputs.{}.follows = \"{}\"",
195+
input.input(),
196+
target
197+
)
198+
};
199+
vec![msg]
191200
}
192201
Change::None => vec![],
193202
}

0 commit comments

Comments
 (0)