Skip to main content

flake_edit/
walk.rs

1//! CST walking and mutation for `flake.nix` files.
2
3mod context;
4mod error;
5mod inputs;
6mod node;
7mod outputs;
8pub(crate) mod toggle;
9
10use std::collections::HashMap;
11
12use rnix::{Root, SyntaxKind, SyntaxNode};
13
14use crate::change::Change;
15use crate::edit::{OutputChange, Outputs};
16use crate::follows::path::follows_idents_prefixed;
17use crate::follows::{AttrPath, Segment, strip_outer_quotes};
18use crate::input::Input;
19
20pub(crate) use context::Context;
21pub use error::WalkerError;
22
23use inputs::walk_inputs;
24use node::{
25    FollowsKind, adjacent_whitespace_index, get_sibling_whitespace, insertion_index_after,
26    last_line_with_newline, make_quoted_string, make_toplevel_flake_false_attr,
27    make_toplevel_url_attr, parse_node, substitute_child,
28};
29
30/// The flake's top-level attribute set.
31///
32/// A flake's root expression is normally a bare attribute set, but it may be
33/// wrapped in `let <bindings> in { ... }`. In that case the inputs and outputs
34/// live in the `in` body, not in the let bindings, so we descend through the
35/// `NODE_LET_IN` to reach the body attrset and walk it exactly as a bare one.
36///
37/// Returns `None` when the root has no expression, or when a `let ... in` body
38/// is not an attribute set (for example a function): such a flake exposes no
39/// inputs or outputs to walk.
40pub(crate) fn flake_attr_set(root: &SyntaxNode) -> Option<SyntaxNode> {
41    let first = root.first_child()?;
42    if first.kind() != SyntaxKind::NODE_LET_IN {
43        return Some(first);
44    }
45    // `let <bindings> in <body>`: the body expression is the last child node,
46    // after the bindings. Only an attrset body carries flake attributes.
47    let body = first.last_child()?;
48    (body.kind() == SyntaxKind::NODE_ATTR_SET).then_some(body)
49}
50
51/// Whether a CST attrpath (idents may carry surrounding `"..."`) matches `expected`
52/// pairwise after unquoting.
53fn idents_match(have: &[String], expected: &[&str]) -> bool {
54    if have.len() != expected.len() {
55        return false;
56    }
57    have.iter()
58        .zip(expected.iter())
59        .all(|(h, e)| strip_outer_quotes(h) == *e)
60}
61
62fn is_flat_inputs_attr_for(idents: &[String], parent_id: &str) -> bool {
63    idents.len() >= 2 && idents[0] == "inputs" && strip_outer_quotes(&idents[1]) == parent_id
64}
65
66fn block_parent_attrset(
67    toplevel: &SyntaxNode,
68    idents: &[String],
69    parent_id: &str,
70) -> Option<SyntaxNode> {
71    if idents.len() != 2 {
72        return None;
73    }
74    if !is_flat_inputs_attr_for(idents, parent_id) {
75        return None;
76    }
77    toplevel
78        .children()
79        .find(|c| c.kind() == SyntaxKind::NODE_ATTR_SET)
80}
81
82/// Same-target hits return the unchanged root. The caller treats `Some` as
83/// "claimed", so the no-op variant must still return `Some` to short-circuit
84/// the surrounding scan.
85fn retarget_existing_flat_follows(
86    attr_set: &SyntaxNode,
87    toplevel: &SyntaxNode,
88    value_node: Option<SyntaxNode>,
89    target: &str,
90) -> Option<SyntaxNode> {
91    let current_target = value_node
92        .as_ref()
93        .map(|v| strip_outer_quotes(&v.to_string()).to_string())
94        .unwrap_or_default();
95
96    if current_target == target {
97        return attr_set.ancestors().last();
98    }
99
100    let value = value_node?;
101    let new_value = make_quoted_string(target);
102    let new_toplevel = substitute_child(toplevel, value.index(), &new_value);
103    let green = attr_set
104        .green()
105        .replace_child(toplevel.index(), new_toplevel.green().into());
106    Some(SyntaxNode::new_root(attr_set.replace_with(green)))
107}
108
109/// Mirrors `ref_child`'s leading newline + indent so the inserted line
110/// reads at the same column as its neighbour. Without normalization, the
111/// raw whitespace token includes any pre-`ref_child` spacing too.
112fn insert_flat_follows_after(
113    attr_set: &SyntaxNode,
114    ref_child: &SyntaxNode,
115    path: &AttrPath,
116    target: &str,
117) -> SyntaxNode {
118    let follows_node = FollowsKind::TopLevelNested { path, target }.emit();
119    let insert_index = insertion_index_after(ref_child);
120
121    let mut green = attr_set
122        .green()
123        .insert_child(insert_index, follows_node.green().into());
124
125    if let Some(whitespace) = get_sibling_whitespace(ref_child) {
126        let ws_str = whitespace.to_string();
127        let ws_node = parse_node(last_line_with_newline(&ws_str));
128        green = green.insert_child(insert_index, ws_node.green().into());
129    }
130
131    SyntaxNode::new_root(attr_set.replace_with(green))
132}
133
134#[derive(Debug, Clone)]
135pub struct Walker {
136    pub(crate) root: SyntaxNode,
137    pub(crate) inputs: HashMap<String, Input>,
138    pub(crate) add_toplevel: bool,
139}
140
141impl<'a> Walker {
142    pub fn new(stream: &'a str) -> Self {
143        let root = Root::parse(stream).syntax();
144        Self::from_root(root)
145    }
146
147    /// Build a walker around an already-parsed root, skipping the rnix parse.
148    /// Lets callers that ran a parse for validation share the result.
149    pub fn from_root(root: SyntaxNode) -> Self {
150        Self {
151            root,
152            inputs: HashMap::new(),
153            add_toplevel: false,
154        }
155    }
156
157    /// Apply `change` to the parsed `flake.nix`, returning the rebuilt root if
158    /// the tree was modified.
159    ///
160    /// Expects the parsed root to be an attrset with `description`, `inputs`, and
161    /// `outputs` keys.
162    pub fn walk(&mut self, change: &Change) -> Result<Option<SyntaxNode>, WalkerError> {
163        let cst = self.root.clone();
164        if cst.kind() != SyntaxKind::NODE_ROOT {
165            return Err(WalkerError::NotARoot);
166        }
167        self.walk_toplevel(cst, None, change)
168    }
169
170    /// List the `outputs` arguments without touching `inputs`.
171    pub(crate) fn list_outputs(&mut self) -> Result<Outputs, WalkerError> {
172        outputs::list_outputs(&self.root)
173    }
174
175    /// Apply an [`OutputChange`] to the `outputs` attribute alone.
176    pub(crate) fn change_outputs(
177        &mut self,
178        change: OutputChange,
179    ) -> Result<Option<SyntaxNode>, WalkerError> {
180        outputs::change_outputs(&self.root, change)
181    }
182
183    /// Walk the top-level attrset, dispatching on `description`/`inputs`/`outputs`.
184    fn walk_toplevel(
185        &mut self,
186        node: SyntaxNode,
187        ctx: Option<Context>,
188        change: &Change,
189    ) -> Result<Option<SyntaxNode>, WalkerError> {
190        let Some(attr_set) = flake_attr_set(&node) else {
191            return Ok(None);
192        };
193
194        for toplevel in attr_set.children() {
195            if toplevel.kind() != SyntaxKind::NODE_ATTRPATH_VALUE {
196                let range = toplevel.text_range();
197                return Err(WalkerError::unexpected_top_level(
198                    &toplevel.to_string(),
199                    range.start().into(),
200                ));
201            }
202
203            // Dispatch on the NODE_ATTRPATH child alone, not on the value.
204            // For `inputs = { ... }` the value is the whole inputs attrset;
205            // stringifying it dominates this walk on large flakes.
206            let Some(attrpath) = toplevel
207                .children()
208                .find(|c| c.kind() == SyntaxKind::NODE_ATTRPATH)
209            else {
210                continue;
211            };
212            let mut path_idents = attrpath.children();
213            let Some(first_ident) = path_idents.next() else {
214                continue;
215            };
216            let has_more_idents = path_idents.next().is_some();
217            let first_text = first_ident.to_string();
218            let first_unquoted = strip_outer_quotes(&first_text);
219
220            if !has_more_idents && first_unquoted == "description" {
221                continue;
222            }
223
224            if first_unquoted == "inputs" {
225                if has_more_idents {
226                    if let Some(result) =
227                        self.handle_inputs_flat(&attr_set, &toplevel, &attrpath, &ctx, change)
228                    {
229                        return Ok(Some(result));
230                    }
231                } else if let Some(result) =
232                    self.handle_inputs_attr(&toplevel, &attrpath, &ctx, change)
233                {
234                    return Ok(Some(result));
235                }
236                continue;
237            }
238
239            if !has_more_idents
240                && first_unquoted == "outputs"
241                && let Some(result) = self.handle_add_at_outputs(&attr_set, &toplevel, change)
242            {
243                return Ok(Some(result));
244            }
245        }
246
247        // Follows on toplevel flat-style inputs (`inputs.X.url = "..."`).
248        if let Change::Follows { input, target } = change {
249            let path = input.path();
250            if path.len() >= 2 {
251                let parent_id = input.input();
252                if self.inputs.contains_key(parent_id.as_str()) {
253                    let target_str = target.to_flake_follows_string();
254                    return self.handle_follows_flat_toplevel(&attr_set, path, &target_str);
255                }
256            }
257        }
258
259        Ok(None)
260    }
261
262    /// Add a follows attribute next to a toplevel flat-style input.
263    ///
264    /// Converts `inputs.crane.url = "github:...";` into:
265    /// ```nix
266    /// inputs.crane.url = "github:...";
267    /// inputs.crane.inputs.nixpkgs.follows = "nixpkgs";
268    /// ```
269    fn handle_follows_flat_toplevel(
270        &self,
271        attr_set: &SyntaxNode,
272        path: &AttrPath,
273        target: &str,
274    ) -> Result<Option<SyntaxNode>, WalkerError> {
275        let parent_id = path.first();
276        let expected_flat = follows_idents_prefixed(path.segments());
277        let mut last_parent_attr: Option<SyntaxNode> = None;
278        let mut block_parent: Option<(SyntaxNode, SyntaxNode)> = None;
279
280        for toplevel in attr_set.children() {
281            if toplevel.kind() != SyntaxKind::NODE_ATTRPATH_VALUE {
282                continue;
283            }
284            let Some(attrpath) = toplevel
285                .children()
286                .find(|c| c.kind() == SyntaxKind::NODE_ATTRPATH)
287            else {
288                continue;
289            };
290            let idents: Vec<String> = attrpath.children().map(|c| c.to_string()).collect();
291
292            if let Some(block_attr_set) =
293                block_parent_attrset(&toplevel, &idents, parent_id.as_str())
294            {
295                block_parent = Some((toplevel.clone(), block_attr_set));
296            }
297
298            if idents_match(&idents, &expected_flat)
299                && let Some(rebuilt) = retarget_existing_flat_follows(
300                    attr_set,
301                    &toplevel,
302                    attrpath.next_sibling(),
303                    target,
304                )
305            {
306                return Ok(Some(rebuilt));
307            }
308
309            if is_flat_inputs_attr_for(&idents, parent_id.as_str()) {
310                last_parent_attr = Some(toplevel.clone());
311            }
312        }
313
314        if let Some((toplevel, block_attr_set)) = block_parent {
315            let rest: Vec<Segment> = path.segments()[1..].to_vec();
316            return self.handle_follows_block_toplevel(
317                attr_set,
318                &toplevel,
319                &block_attr_set,
320                &rest,
321                target,
322            );
323        }
324
325        if let Some(ref_child) = last_parent_attr {
326            return Ok(Some(insert_flat_follows_after(
327                attr_set, &ref_child, path, target,
328            )));
329        }
330
331        Ok(None)
332    }
333
334    fn handle_follows_block_toplevel(
335        &self,
336        attr_set: &SyntaxNode,
337        toplevel: &SyntaxNode,
338        block_attr_set: &SyntaxNode,
339        rest: &[Segment],
340        target: &str,
341    ) -> Result<Option<SyntaxNode>, WalkerError> {
342        let expected_block = follows_idents_prefixed(rest);
343        for attr in block_attr_set.children() {
344            if attr.kind() != SyntaxKind::NODE_ATTRPATH_VALUE {
345                continue;
346            }
347            let Some(attrpath) = attr
348                .children()
349                .find(|c| c.kind() == SyntaxKind::NODE_ATTRPATH)
350            else {
351                continue;
352            };
353            let idents: Vec<String> = attrpath.children().map(|c| c.to_string()).collect();
354
355            if idents_match(&idents, &expected_block) {
356                let value_node = attrpath.next_sibling();
357                let current_target = value_node
358                    .as_ref()
359                    .map(|v| strip_outer_quotes(&v.to_string()).to_string())
360                    .unwrap_or_default();
361
362                if current_target == target {
363                    return Ok(attr_set.ancestors().last());
364                }
365
366                if let Some(value) = value_node {
367                    let new_value = make_quoted_string(target);
368                    let new_attr = substitute_child(&attr, value.index(), &new_value);
369                    let new_block = substitute_child(block_attr_set, attr.index(), &new_attr);
370                    let new_toplevel =
371                        substitute_child(toplevel, block_attr_set.index(), &new_block);
372                    let green = attr_set
373                        .green()
374                        .replace_child(toplevel.index(), new_toplevel.green().into());
375                    return Ok(Some(SyntaxNode::new_root(attr_set.replace_with(green))));
376                }
377            }
378        }
379
380        let follows_node = FollowsKind::BlockNested { rest, target }.emit();
381        let children: Vec<_> = block_attr_set.children().collect();
382        if let Some(last_child) = children.last() {
383            let insert_index = last_child.index() + 1;
384
385            let mut green = block_attr_set
386                .green()
387                .insert_child(insert_index, follows_node.green().into());
388
389            if let Some(whitespace) = get_sibling_whitespace(last_child) {
390                green = green.insert_child(insert_index, whitespace.green().into());
391            }
392
393            let new_block = SyntaxNode::new_root(green);
394            let new_toplevel = substitute_child(toplevel, block_attr_set.index(), &new_block);
395            let green = attr_set
396                .green()
397                .replace_child(toplevel.index(), new_toplevel.green().into());
398            return Ok(Some(SyntaxNode::new_root(attr_set.replace_with(green))));
399        }
400
401        Ok(None)
402    }
403
404    /// Apply `change` to the `inputs = { ... }` attribute.
405    ///
406    /// `toplevel.replace_with()` propagates through `NODE_ATTR_SET` up to `NODE_ROOT`,
407    /// preserving leading comments and trivia.
408    fn handle_inputs_attr(
409        &mut self,
410        toplevel: &SyntaxNode,
411        child: &SyntaxNode,
412        ctx: &Option<Context>,
413        change: &Change,
414    ) -> Option<SyntaxNode> {
415        let sibling = child.next_sibling()?;
416        let replacement = walk_inputs(&mut self.inputs, sibling.clone(), ctx, change)?;
417
418        let green = toplevel
419            .green()
420            .replace_child(sibling.index(), replacement.green().into());
421        let green = toplevel.replace_with(green);
422        Some(SyntaxNode::new_root(green))
423    }
424
425    /// Apply `change` to flat-style `inputs.foo.url = "..."` attributes.
426    ///
427    /// Removals rebuild the parent attrset green and `replace_with()` propagates to
428    /// `NODE_ROOT`. Replacements rely on `toplevel.replace_with()` to propagate.
429    fn handle_inputs_flat(
430        &mut self,
431        attr_set: &SyntaxNode,
432        toplevel: &SyntaxNode,
433        child: &SyntaxNode,
434        ctx: &Option<Context>,
435        change: &Change,
436    ) -> Option<SyntaxNode> {
437        let replacement = walk_inputs(&mut self.inputs, child.clone(), ctx, change)?;
438
439        // Empty replacement means we remove the entire toplevel node and
440        // propagate through attr_set to NODE_ROOT.
441        if replacement.to_string().is_empty() {
442            let element: rnix::SyntaxElement = toplevel.clone().into();
443            let mut green = attr_set.green().remove_child(toplevel.index());
444            if let Some(ws_index) = adjacent_whitespace_index(&element) {
445                green = green.remove_child(ws_index);
446            }
447            return Some(SyntaxNode::new_root(attr_set.replace_with(green)));
448        }
449
450        let sibling = child.next_sibling()?;
451        let green = toplevel
452            .green()
453            .replace_child(sibling.index(), replacement.green().into());
454        let green = toplevel.replace_with(green);
455        Some(SyntaxNode::new_root(green))
456    }
457
458    /// Add a new input just before `outputs` when no `inputs` block exists yet.
459    ///
460    /// Rebuilds the parent attrset green. `replace_with()` propagates to `NODE_ROOT`
461    /// while preserving leading comments.
462    fn handle_add_at_outputs(
463        &mut self,
464        attr_set: &SyntaxNode,
465        toplevel: &SyntaxNode,
466        change: &Change,
467    ) -> Option<SyntaxNode> {
468        if !self.add_toplevel {
469            return None;
470        }
471
472        let Change::Add {
473            id: Some(id),
474            uri: Some(uri),
475            flake,
476        } = change
477        else {
478            return None;
479        };
480        let id = id.input().as_str();
481
482        if toplevel.index() == 0 {
483            return None;
484        }
485
486        // Walk back from `outputs` through tokens to find a whitespace run, then
487        // normalize it to a single newline + indent. Walking through tokens (not
488        // siblings) lets us skip past comments between the last input and `outputs`.
489        let ws_node = {
490            let mut ws: Option<SyntaxNode> = None;
491            let mut cursor = toplevel.prev_sibling_or_token();
492            while let Some(ref tok) = cursor {
493                if tok.kind() == SyntaxKind::TOKEN_WHITESPACE {
494                    let ws_str = tok.to_string();
495                    ws = Some(parse_node(last_line_with_newline(&ws_str)));
496                    break;
497                }
498                cursor = tok.prev_sibling_or_token();
499            }
500            ws
501        };
502
503        let addition = make_toplevel_url_attr(id, uri);
504        let insert_pos = toplevel.index() - 1;
505
506        let mut green = attr_set
507            .green()
508            .insert_child(insert_pos, addition.green().into());
509
510        if let Some(ref ws) = ws_node {
511            green = green.insert_child(insert_pos, ws.green().into());
512        }
513
514        // Append `inputs.<id>.flake = false;` when the new input opts out of flake mode.
515        if !flake {
516            let no_flake = make_toplevel_flake_false_attr(id);
517            green = green.insert_child(toplevel.index() + 1, no_flake.green().into());
518
519            if let Some(ref ws) = ws_node {
520                green = green.insert_child(toplevel.index() + 1, ws.green().into());
521            }
522        }
523
524        Some(SyntaxNode::new_root(attr_set.replace_with(green)))
525    }
526}
527
528#[cfg(test)]
529mod tests {
530    use super::*;
531    use crate::change::{Change, ChangeId};
532    use crate::follows::AttrPath;
533
534    fn apply(flake_text: &str, change: &Change) -> String {
535        let mut walker = Walker::new(flake_text);
536        walker
537            .walk(change)
538            .expect("walker error")
539            .expect("walker did not rewrite the tree")
540            .to_string()
541    }
542
543    fn follows_change(input: &str, target: &str) -> Change {
544        Change::Follows {
545            input: ChangeId::parse(input).unwrap(),
546            target: AttrPath::parse(target).unwrap(),
547        }
548    }
549
550    #[test]
551    fn handle_follows_flat_toplevel_inserts_follows_after_last_parent_attr() {
552        let flake = "{
553  inputs.flake-edit.url = \"github:a-kenji/flake-edit\";
554  inputs.nixpkgs.url = \"github:NixOS/nixpkgs\";
555
556  outputs = { self, ... }: { };
557}
558";
559        let result = apply(flake, &follows_change("flake-edit.nixpkgs", "nixpkgs"));
560        assert_eq!(
561            result,
562            "{
563  inputs.flake-edit.url = \"github:a-kenji/flake-edit\";
564  inputs.flake-edit.inputs.nixpkgs.follows = \"nixpkgs\";
565  inputs.nixpkgs.url = \"github:NixOS/nixpkgs\";
566
567  outputs = { self, ... }: { };
568}
569"
570        );
571    }
572
573    #[test]
574    fn handle_follows_flat_toplevel_retargets_existing_follows() {
575        let flake = "{
576  inputs.flake-edit.url = \"github:a-kenji/flake-edit\";
577  inputs.flake-edit.inputs.nixpkgs.follows = \"old-pkgs\";
578  inputs.nixpkgs.url = \"github:NixOS/nixpkgs\";
579
580  outputs = { self, ... }: { };
581}
582";
583        let result = apply(flake, &follows_change("flake-edit.nixpkgs", "nixpkgs"));
584        assert_eq!(
585            result,
586            "{
587  inputs.flake-edit.url = \"github:a-kenji/flake-edit\";
588  inputs.flake-edit.inputs.nixpkgs.follows = \"nixpkgs\";
589  inputs.nixpkgs.url = \"github:NixOS/nixpkgs\";
590
591  outputs = { self, ... }: { };
592}
593"
594        );
595    }
596
597    #[test]
598    fn handle_follows_flat_toplevel_is_noop_when_target_already_matches() {
599        let flake = "{
600  inputs.flake-edit.url = \"github:a-kenji/flake-edit\";
601  inputs.flake-edit.inputs.nixpkgs.follows = \"nixpkgs\";
602  inputs.nixpkgs.url = \"github:NixOS/nixpkgs\";
603
604  outputs = { self, ... }: { };
605}
606";
607        let result = apply(flake, &follows_change("flake-edit.nixpkgs", "nixpkgs"));
608        assert_eq!(result, flake);
609    }
610
611    #[test]
612    fn handle_follows_flat_toplevel_delegates_to_block_parent_when_present() {
613        let flake = "{
614  inputs.nixpkgs.url = \"github:NixOS/nixpkgs\";
615  inputs.flake-edit = {
616    url = \"github:a-kenji/flake-edit\";
617  };
618
619  outputs = { self, ... }: { };
620}
621";
622        let result = apply(flake, &follows_change("flake-edit.nixpkgs", "nixpkgs"));
623        assert_eq!(
624            result,
625            "{
626  inputs.nixpkgs.url = \"github:NixOS/nixpkgs\";
627  inputs.flake-edit = {
628    url = \"github:a-kenji/flake-edit\";
629    inputs.nixpkgs.follows = \"nixpkgs\";
630  };
631
632  outputs = { self, ... }: { };
633}
634"
635        );
636    }
637
638    #[test]
639    fn is_flat_inputs_attr_for_only_matches_matching_parent_id() {
640        let yes = [
641            "inputs".to_string(),
642            "flake-edit".to_string(),
643            "url".to_string(),
644        ];
645        let no = [
646            "inputs".to_string(),
647            "nixpkgs".to_string(),
648            "url".to_string(),
649        ];
650        assert!(is_flat_inputs_attr_for(&yes, "flake-edit"));
651        assert!(!is_flat_inputs_attr_for(&no, "flake-edit"));
652        // The CST keeps surrounding `"..."` on quoted idents; the comparison
653        // must unquote them, otherwise `"flake-edit" != flake-edit`.
654        let quoted = [
655            "inputs".to_string(),
656            "\"flake-edit\"".to_string(),
657            "url".to_string(),
658        ];
659        assert!(is_flat_inputs_attr_for(&quoted, "flake-edit"));
660    }
661}