@@ -4,7 +4,7 @@ use crate::evolve::types::SemanticFileEdit;
44use anyhow:: { Context , Result } ;
55use log:: { debug, info} ;
66use rnix:: SyntaxNode ;
7- use rnix:: ast:: { AttrSet , List } ;
7+ use rnix:: ast:: { AttrSet , HasEntry , List } ;
88use rnix:: { Parse , Root } ;
99use rowan:: ast:: AstNode ;
1010use 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+
609668fn 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