Skip to content

Commit 0b49374

Browse files
amacbrideclaude
andauthored
fix(evolve): resolve nested attrset leaves in nix_file_editor add() (#396)
The non-list duplicate guard added earlier used find_assignment_value_range, which matches only a flat dotted LHS (`a.b.c = …`). When the attr was instead defined nested inside an attrset literal — e.g. system.defaults.dock = { magnification = 1; mru-spaces = 0; }; — the guard could not see `system.defaults.dock.mru-spaces`, so add() fell through and inserted a second `system.defaults.dock.mru-spaces = [ … ];`, producing two assignments to the same attribute (invalid Nix). This is the case Scott hit in real configs. Add a structural resolver (find_attrpath_value_node) that walks the parsed AST, descending through attrset literals as well as flat dotted assignments, and returns the leaf's value node regardless of how it was written. add() now: - appends when the leaf resolves to a list (including lists nested deeper than find_list_for_attrpath reconstructs), - errors when it resolves to a non-list (flat or nested), - inserts only when the leaf is genuinely absent. Known limitation (documented): a quoted key containing dots, e.g. `NSGlobalDomain."com.apple.sound.beep.feedback"`, is one AST segment but splits into several here, so such paths still won't resolve. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 8a3e7e7 commit 0b49374

1 file changed

Lines changed: 126 additions & 7 deletions

File tree

apps/native/src-tauri/src/evolve/nix_file_editor.rs

Lines changed: 126 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use crate::evolve::types::SemanticFileEdit;
44
use anyhow::{Context, Result};
55
use log::{debug, info};
66
use rnix::SyntaxNode;
7-
use rnix::ast::{AttrSet, List};
7+
use rnix::ast::{AttrSet, HasEntry, List};
88
use rnix::{Parse, Root};
99
use rowan::ast::AstNode;
1010
use rowan::{TextRange, TextSize};
@@ -606,6 +606,65 @@ pub(crate) fn infer_single_list_attrpath(content: &str) -> Result<Option<String>
606606
Ok(inferred)
607607
}
608608

609+
/// Resolve a dot-separated `attrpath` to the value expression it is assigned,
610+
/// descending through attrset *literals* (`a = { b = …; }`) as well as flat
611+
/// dotted assignments (`a.b = …`) and any mix of the two. Returns the value
612+
/// node if the leaf is defined anywhere, regardless of its type.
613+
///
614+
/// This is the structural counterpart to `find_assignment_value_range`, which
615+
/// matches only a flat dotted LHS and so cannot see a leaf nested inside an
616+
/// attrset literal — the case that let `add()` insert a duplicate assignment.
617+
///
618+
/// Known limitation: a quoted key containing dots (e.g.
619+
/// `NSGlobalDomain."com.apple.sound.beep.feedback"`) is one AST segment but
620+
/// splits into several here, so such paths won't resolve. That matches the
621+
/// existing dot-splitting behaviour elsewhere in this module.
622+
fn find_attrpath_value_node(root: &SyntaxNode, attrpath: &str) -> Option<SyntaxNode> {
623+
let target: Vec<String> = attrpath
624+
.split('.')
625+
.map(normalize_attrpath_for_match)
626+
.filter(|segment| !segment.is_empty())
627+
.collect();
628+
if target.is_empty() {
629+
return None;
630+
}
631+
632+
// The top-level expression of a module is its outermost attrset.
633+
let top = AttrSet::cast(root.clone()).or_else(|| root.descendants().find_map(AttrSet::cast))?;
634+
resolve_in_attrset(&top, &target)
635+
}
636+
637+
fn resolve_in_attrset(attrset: &AttrSet, target: &[String]) -> Option<SyntaxNode> {
638+
for entry in attrset.attrpath_values() {
639+
let Some(attrpath) = entry.attrpath() else {
640+
continue;
641+
};
642+
let key: Vec<String> = attrpath
643+
.attrs()
644+
.map(|attr| normalize_attrpath_for_match(&attr.syntax().text().to_string()))
645+
.collect();
646+
// The entry's key must be a (possibly partial) prefix of what we want.
647+
if key.is_empty() || key.len() > target.len() || key[..] != target[..key.len()] {
648+
continue;
649+
}
650+
let Some(value) = entry.value() else {
651+
continue;
652+
};
653+
let value_node = value.syntax().clone();
654+
if key.len() == target.len() {
655+
return Some(value_node);
656+
}
657+
// Strict prefix: the remaining segments must resolve inside this
658+
// entry's attrset value (e.g. `dock` matched, `mru-spaces` is inside).
659+
if let Some(inner) = AttrSet::cast(value_node) {
660+
if let Some(found) = resolve_in_attrset(&inner, &target[key.len()..]) {
661+
return Some(found);
662+
}
663+
}
664+
}
665+
None
666+
}
667+
609668
fn append_values_to_existing_list(
610669
content: &str,
611670
list_node: &SyntaxNode,
@@ -719,7 +778,20 @@ fn add(content: &str, attrpath: &str, values: &[String]) -> Result<String> {
719778
let root_node_ref: &SyntaxNode = root.syntax();
720779
let root_node: SyntaxNode = root_node_ref.clone();
721780

722-
if let Some(list_node) = find_list_for_attrpath(&root_node, content, attrpath) {
781+
// Resolve the attrpath structurally (through attrset literals as well as
782+
// flat dotted assignments) so we can tell "already defined" from "absent"
783+
// even when the definition is nested inside an attrset.
784+
let resolved = find_attrpath_value_node(&root_node, attrpath);
785+
786+
// An existing list to append to: the flat fast-path first, then a nested
787+
// list the resolver found (e.g. defined deeper than one attrset level).
788+
let existing_list = find_list_for_attrpath(&root_node, content, attrpath).or_else(|| {
789+
resolved
790+
.clone()
791+
.filter(|node| List::cast(node.clone()).is_some())
792+
});
793+
794+
if let Some(list_node) = existing_list {
723795
debug!("Found existing list for {}", attrpath);
724796
let mut items = extract_package_list(&list_node)?;
725797
let mut values_to_append = Vec::new();
@@ -744,11 +816,12 @@ fn add(content: &str, attrpath: &str, values: &[String]) -> Result<String> {
744816
return append_values_to_existing_list(content, &list_node, &values_to_append);
745817
}
746818

747-
// No list was found at this attrpath. If the attrpath already exists as a
748-
// non-list assignment (e.g. `programs.git = { enable = true; };`), inserting
749-
// a fresh `attrpath = [ ... ];` would produce two assignments to the same
750-
// attribute — invalid Nix. Refuse rather than silently corrupt the file.
751-
if find_assignment_value_range(content, attrpath).is_some() {
819+
// No appendable list was found. If the attrpath is already defined as a
820+
// non-list — whether flat (`programs.git = { … };`) or nested inside an
821+
// attrset literal (`system.defaults.dock = { mru-spaces = 0; };`) —
822+
// inserting a fresh `attrpath = [ … ];` would produce two assignments to
823+
// the same attribute, which is invalid Nix. Refuse rather than corrupt it.
824+
if resolved.is_some() {
752825
return Err(anyhow::anyhow!(
753826
"Cannot add list values to '{}': it already exists and is not a list",
754827
attrpath
@@ -1102,6 +1175,52 @@ environment.systemPackages = with pkgs; [
11021175
);
11031176
}
11041177

1178+
#[test]
1179+
fn add_refuses_to_duplicate_nested_attrset_leaf() {
1180+
// `mru-spaces` is defined *nested inside* an attrset literal, not as a
1181+
// flat `system.defaults.dock.mru-spaces = ...` assignment. The flat-only
1182+
// guard missed this and `add()` inserted a duplicate; the structural
1183+
// resolver must catch it and error instead.
1184+
let src =
1185+
"{\n system.defaults.dock = {\n magnification = 1;\n mru-spaces = 0;\n };\n}\n";
1186+
let err = add(src, "system.defaults.dock.mru-spaces", &["foo".to_string()])
1187+
.expect_err("add to a nested non-list leaf should error, not duplicate it");
1188+
assert!(
1189+
err.to_string().contains("not a list"),
1190+
"unexpected error: {err:#}"
1191+
);
1192+
}
1193+
1194+
#[test]
1195+
fn add_inserts_new_leaf_under_existing_attrset() {
1196+
// A genuinely-absent leaf must still be inserted (no false positive from
1197+
// the resolver just because a sibling attrset exists at a prefix).
1198+
let src = "{\n system.defaults.dock = {\n mru-spaces = 0;\n };\n}\n";
1199+
let out = add(src, "environment.systemPackages", &["foo".to_string()])
1200+
.expect("adding a brand-new attrpath should succeed");
1201+
assert_eq!(
1202+
out.matches("environment.systemPackages").count(),
1203+
1,
1204+
"new attr should be inserted exactly once"
1205+
);
1206+
}
1207+
1208+
#[test]
1209+
fn add_appends_to_list_nested_in_attrset() {
1210+
// A list nested deeper than `find_list_for_attrpath` reconstructs must be
1211+
// appended to via the resolver, not duplicated.
1212+
let src =
1213+
"{\n programs.foo = {\n settings = {\n tags = [ \"a\" ];\n };\n };\n}\n";
1214+
let out = add(src, "programs.foo.settings.tags", &["b".to_string()])
1215+
.expect("appending to a nested list should succeed");
1216+
assert_eq!(
1217+
out.matches("tags =").count(),
1218+
1,
1219+
"nested list must not be duplicated"
1220+
);
1221+
assert!(out.contains('b'), "appended value should be present: {out}");
1222+
}
1223+
11051224
#[test]
11061225
fn remove_updates_existing_with_pkgs_list() {
11071226
let with_item = add(

0 commit comments

Comments
 (0)