Skip to main content

flake_edit/
edit.rs

1use std::collections::{BTreeMap, HashMap};
2
3use crate::change::Change;
4use crate::error::Error;
5use crate::input::{Follows, Input};
6use crate::validate;
7use crate::walk::{Walker, toggle};
8
9pub struct FlakeEdit {
10    walker: Walker,
11}
12
13#[derive(Default, Debug)]
14pub enum Outputs {
15    #[default]
16    None,
17    Multiple(Vec<String>),
18    Any(Vec<String>),
19}
20
21pub type InputMap = HashMap<String, Input>;
22
23/// Sorted input ids from `inputs`.
24pub fn sorted_input_ids(inputs: &InputMap) -> Vec<&String> {
25    let mut keys: Vec<_> = inputs.keys().collect();
26    keys.sort();
27    keys
28}
29
30#[derive(Default, Debug)]
31pub enum OutputChange {
32    #[default]
33    None,
34    Add(String),
35    Remove(String),
36}
37
38/// Toggle surface of one input: its active url and the stored alternates
39/// adjacent to it, in file order.
40#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
41pub struct ToggleState {
42    /// The currently active url.
43    pub active: String,
44    /// Urls of the commented alternates next to the active binding.
45    pub alternates: Vec<String>,
46}
47
48/// Result of applying a [`Change`].
49///
50/// `text` is the new flake source, or `None` for a no-op (e.g. an
51/// already-existing follows declaration).
52#[derive(Debug, Default)]
53pub struct ApplyOutcome {
54    pub text: Option<String>,
55}
56
57impl FlakeEdit {
58    pub fn from_text(stream: &str) -> Result<Self, Error> {
59        let parsed = validate::ParsedSource::new(stream);
60        let validation = validate::validate_parsed(&parsed);
61        if validation.has_errors() {
62            return Err(Error::Validation(validation.errors));
63        }
64
65        let walker = Walker::from_root(parsed.syntax);
66        Ok(Self { walker })
67    }
68
69    /// Wrap an already-parsed `flake.nix` syntax tree, skipping the parse and
70    /// validation that [`Self::from_text`] runs. Reserved for the auto-follow
71    /// apply loop, where each iteration validates its result and feeds the
72    /// resulting parse back to the next iteration's walker.
73    #[cfg(feature = "application")]
74    pub(crate) fn from_syntax(syntax: rnix::SyntaxNode) -> Self {
75        Self {
76            walker: Walker::from_root(syntax),
77        }
78    }
79
80    pub fn source_text(&self) -> String {
81        self.walker.root.to_string()
82    }
83
84    pub fn curr_list(&self) -> &InputMap {
85        &self.walker.inputs
86    }
87
88    /// Re-walk the source and return the freshly populated input map. Use
89    /// [`Self::curr_list`] to read the cached map without re-walking.
90    pub fn list(&mut self) -> &InputMap {
91        self.walker.inputs.clear();
92        // Walk returns Ok(None) when no changes are made (expected for listing)
93        assert!(self.walker.walk(&Change::None).ok().flatten().is_none());
94        &self.walker.inputs
95    }
96    /// Apply `change` and return the resulting [`ApplyOutcome`].
97    ///
98    /// Some edits require multiple walker passes. This method drives them all.
99    /// A fatal validation failure surfaces as
100    /// [`Error::Validation`].
101    ///
102    /// # Errors
103    ///
104    /// Returns [`Error`] if the underlying walker fails or the change
105    /// is rejected (e.g. [`Error::DuplicateInput`],
106    /// [`Error::InputNotFound`]).
107    pub fn apply_change(&mut self, change: Change) -> Result<ApplyOutcome, Error> {
108        let text = self.apply_change_text(change)?;
109        Ok(ApplyOutcome { text })
110    }
111
112    fn apply_change_text(&mut self, change: Change) -> Result<Option<String>, Error> {
113        match change {
114            Change::None => Ok(None),
115            Change::Add { .. } => self.apply_add(change),
116            Change::Remove { .. } => self.apply_remove(change),
117            Change::Follows { .. } => self.apply_follows(change),
118            Change::Change { .. } => self.apply_change_uri(change),
119            Change::Toggle { .. } => self.apply_toggle(change),
120            Change::ToggleRemove { .. } => self.apply_toggle_remove(change),
121        }
122    }
123
124    /// The `Change::Add` path is two-shot: a first walk attempts to insert
125    /// inside an existing `inputs = { ... }` block, and only if that returns
126    /// `None` (no such block) does the walker re-run with `add_toplevel`
127    /// flipped on to synthesize one. Outputs-lambda extension piggy-backs on
128    /// the first walk because it must observe the post-insert syntax tree.
129    fn apply_add(&mut self, change: Change) -> Result<Option<String>, Error> {
130        if let Some(input_id) = change.id() {
131            self.ensure_inputs_populated()?;
132
133            let input_id_string = input_id.input().as_str().to_string();
134            if self.walker.inputs.contains_key(&input_id_string) {
135                return Err(Error::DuplicateInput(input_id_string));
136            }
137        }
138
139        if let Some(maybe_changed_node) = self.walker.walk(&change.clone())? {
140            let outputs = self.walker.list_outputs()?;
141            match outputs {
142                Outputs::Multiple(out) => {
143                    let id = change.id().unwrap().input().as_str().to_string();
144                    if !out.contains(&id) {
145                        self.walker.root = maybe_changed_node.clone();
146                        if let Some(maybe_changed_node) =
147                            self.walker.change_outputs(OutputChange::Add(id))?
148                        {
149                            return Ok(Some(maybe_changed_node.to_string()));
150                        }
151                    }
152                }
153                Outputs::None | Outputs::Any(_) => {}
154            }
155            Ok(Some(maybe_changed_node.to_string()))
156        } else {
157            self.walker.add_toplevel = true;
158            let maybe_changed_node = self.walker.walk(&change)?;
159            Ok(maybe_changed_node.map(|n| n.to_string()))
160        }
161    }
162
163    /// `Change::Remove` runs the walker in a fixed-point loop because a single
164    /// input can be spelled across multiple flat declarations
165    /// (`inputs.foo.url = ...; inputs.foo.flake = false;`); each walk strips
166    /// one occurrence. The post-loop outputs-lambda strip and orphan-follows
167    /// scrub only run for a top-level remove, since a depth-N follows id
168    /// shares its first segment with a still-present input and running the
169    /// cleanup there would strip that input from the outputs lambda.
170    fn apply_remove(&mut self, change: Change) -> Result<Option<String>, Error> {
171        self.ensure_inputs_populated()?;
172
173        let id = change.id().unwrap();
174        let is_toplevel_remove = id.follows().is_none();
175        let removed_id = id.input().as_str().to_string();
176
177        let mut res = None;
178        while let Some(changed_node) = self.walker.walk(&change)? {
179            if res == Some(changed_node.clone()) {
180                break;
181            }
182            res = Some(changed_node.clone());
183            self.walker.root = changed_node.clone();
184        }
185
186        if is_toplevel_remove {
187            let outputs = self.walker.list_outputs()?;
188            match outputs {
189                Outputs::Multiple(out) | Outputs::Any(out) => {
190                    if out.contains(&removed_id)
191                        && let Some(changed_node) = self
192                            .walker
193                            .change_outputs(OutputChange::Remove(removed_id.clone()))?
194                    {
195                        res = Some(changed_node.clone());
196                        self.walker.root = changed_node.clone();
197                    }
198                }
199                Outputs::None => {}
200            }
201
202            let orphaned_follows = self.collect_orphaned_follows(&removed_id);
203            for orphan_change in orphaned_follows {
204                while let Some(changed_node) = self.walker.walk(&orphan_change)? {
205                    if res == Some(changed_node.clone()) {
206                        break;
207                    }
208                    res = Some(changed_node.clone());
209                    self.walker.root = changed_node.clone();
210                }
211            }
212        }
213
214        Ok(res.map(|n| n.to_string()))
215    }
216
217    /// A `Change::Follows` whose parent input is missing is a hard error
218    /// rather than a no-op so the caller learns about the typo; the parent
219    /// check runs before the walk because the walker would silently produce
220    /// no edit otherwise.
221    fn apply_follows(&mut self, change: Change) -> Result<Option<String>, Error> {
222        let Change::Follows { ref input, .. } = change else {
223            unreachable!("apply_follows dispatched only for Change::Follows");
224        };
225
226        self.ensure_inputs_populated()?;
227
228        let parent_id = input.input().as_str();
229        if !self.walker.inputs.contains_key(parent_id) {
230            return Err(Error::InputNotFound(parent_id.to_string()));
231        }
232
233        Ok(self.walker.walk(&change)?.map(|n| n.to_string()))
234    }
235
236    /// The presence check exists because `walker.walk` produces no edit at
237    /// all when its `Change::Change` target is missing, so without surfacing
238    /// `InputNotFound` here a typo would silently report success. The
239    /// `Option<ChangeId>` is honored rather than asserted: a `Change::Change`
240    /// with `id == None` is a no-op the walker handles without consulting
241    /// the input map.
242    fn apply_change_uri(&mut self, change: Change) -> Result<Option<String>, Error> {
243        if let Some(input_id) = change.id() {
244            self.ensure_inputs_populated()?;
245
246            let input_id_string = input_id.input().as_str().to_string();
247            if !self.walker.inputs.contains_key(&input_id_string) {
248                return Err(Error::InputNotFound(input_id_string));
249            }
250        }
251
252        Ok(self.walker.walk(&change)?.map(|n| n.to_string()))
253    }
254
255    /// A `Change::Toggle` edits through [`crate::walk::toggle`] directly
256    /// rather than the traversal in `walk`: the url binding is found via
257    /// the range recorded on the input, and the flip or new-alternate
258    /// write is a local green-tree rewrite around it.
259    fn apply_toggle(&mut self, change: Change) -> Result<Option<String>, Error> {
260        let Change::Toggle { id, uri, .. } = change else {
261            unreachable!("apply_toggle dispatched only for Change::Toggle");
262        };
263
264        self.ensure_inputs_populated()?;
265
266        let id_str = id.input().as_str().to_string();
267        let Some(input) = self.walker.inputs.get(&id_str) else {
268            return Err(Error::InputNotFound(id_str));
269        };
270        let Some(binding) = toggle::url_binding(&self.walker.root, input) else {
271            return Err(Error::NoUrlToToggle(id_str));
272        };
273        let parent = binding
274            .parent()
275            .expect("a url binding always sits inside an enclosing node");
276
277        let alternates = toggle::alternates(&binding);
278        if let Some(alternate) = alternates.iter().find(|a| a.url == uri) {
279            return Ok(Some(toggle::flip(&parent, &binding, alternate).to_string()));
280        }
281        if input.url() == uri {
282            // The url is already active and not stored as an alternate.
283            // Creating one would write a duplicate line below it, so this
284            // is a no-op.
285            return Ok(None);
286        }
287        Ok(Some(
288            toggle::synthesize(&parent, &binding, &uri).to_string(),
289        ))
290    }
291
292    /// A `Change::ToggleRemove` deletes the resolved variant's line through
293    /// [`crate::walk::toggle`]. Removing a stored alternate drops its
294    /// comment and leaves the active url untouched. Removing the active url
295    /// activates `activate` in its place and drops the previously active
296    /// line. A `uri` that is not stored on the input is a no-op.
297    fn apply_toggle_remove(&mut self, change: Change) -> Result<Option<String>, Error> {
298        let Change::ToggleRemove { id, uri, activate } = change else {
299            unreachable!("apply_toggle_remove dispatched only for Change::ToggleRemove");
300        };
301
302        self.ensure_inputs_populated()?;
303
304        let id_str = id.input().as_str().to_string();
305        let Some(input) = self.walker.inputs.get(&id_str) else {
306            return Err(Error::InputNotFound(id_str));
307        };
308        let Some(binding) = toggle::url_binding(&self.walker.root, input) else {
309            return Err(Error::NoUrlToToggle(id_str));
310        };
311        let parent = binding
312            .parent()
313            .expect("a url binding always sits inside an enclosing node");
314
315        let alternates = toggle::alternates(&binding);
316        if let Some(alternate) = alternates.iter().find(|a| a.url == uri) {
317            return Ok(Some(
318                toggle::remove_alternate(&parent, alternate).to_string(),
319            ));
320        }
321        if input.url() == uri {
322            let replacement = activate
323                .and_then(|activate| alternates.into_iter().find(|a| a.url == activate))
324                .ok_or(Error::RemoveActiveWithoutAlternate(id_str))?;
325            return Ok(Some(
326                toggle::flip_remove(&parent, &binding, &replacement).to_string(),
327            ));
328        }
329        Ok(None)
330    }
331
332    /// Toggle states for every input with a url binding, keyed by input
333    /// id. Inputs without one (follows-only inputs) are absent. An input
334    /// is toggleable when its state lists at least one alternate.
335    pub fn toggle_states(&mut self) -> Result<BTreeMap<String, ToggleState>, Error> {
336        self.ensure_inputs_populated()?;
337        let mut states = BTreeMap::new();
338        for (id, input) in &self.walker.inputs {
339            let Some(binding) = toggle::url_binding(&self.walker.root, input) else {
340                continue;
341            };
342            let alternates = toggle::alternates(&binding)
343                .into_iter()
344                .map(|a| a.url)
345                .collect();
346            states.insert(
347                id.clone(),
348                ToggleState {
349                    active: input.url().to_string(),
350                    alternates,
351                },
352            );
353        }
354        Ok(states)
355    }
356
357    pub fn walker(&self) -> &Walker {
358        &self.walker
359    }
360
361    /// Walk once if the inputs map is empty.
362    fn ensure_inputs_populated(&mut self) -> Result<(), Error> {
363        if self.walker.inputs.is_empty() {
364            let _ = self.walker.walk(&Change::None)?;
365        }
366        Ok(())
367    }
368
369    /// Collect [`Change::Remove`]s for follows declarations whose target
370    /// top-level segment matches `removed_id`.
371    fn collect_orphaned_follows(&self, removed_id: &str) -> Vec<Change> {
372        let mut orphaned = Vec::new();
373        for (input_id, input) in &self.walker.inputs {
374            for follows in input.follows() {
375                if let Follows::Indirect {
376                    path,
377                    target: Some(target),
378                } = follows
379                {
380                    // target is the RHS of `follows = "..."`. Match when its
381                    // top-level segment is the removed input. Empty targets
382                    // (`follows = ""`) have nothing to follow and cannot
383                    // dangle.
384                    if target.first().as_str() == removed_id {
385                        let path_str = format!("{}.{}", input_id, path);
386                        let Ok(change_id) = crate::change::ChangeId::parse(&path_str) else {
387                            continue;
388                        };
389                        orphaned.push(Change::Remove {
390                            ids: vec![change_id],
391                        });
392                    }
393                }
394            }
395        }
396        orphaned
397    }
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403
404    fn flake_with_nixpkgs_and_crane() -> &'static str {
405        r#"{
406  inputs = {
407    nixpkgs.url = "github:nixos/nixpkgs";
408    crane = {
409      url = "github:ipetkov/crane";
410    };
411  };
412  outputs = { ... }: { };
413}"#
414    }
415
416    #[test]
417    fn none_is_noop() {
418        let mut fe = FlakeEdit::from_text(flake_with_nixpkgs_and_crane()).unwrap();
419        let outcome = fe.apply_change(Change::None).unwrap();
420        assert!(outcome.text.is_none(), "Change::None must not produce text");
421    }
422
423    #[test]
424    fn add_inserts_into_existing_inputs_block() {
425        let flake = r#"{
426  inputs = {
427    nixpkgs.url = "github:nixos/nixpkgs";
428  };
429  outputs = { ... }: { };
430}"#;
431        let mut fe = FlakeEdit::from_text(flake).unwrap();
432        let change = Change::Add {
433            id: Some(crate::change::ChangeId::parse("crane").unwrap()),
434            uri: Some("github:ipetkov/crane".into()),
435            flake: true,
436        };
437        let text = fe
438            .apply_change(change)
439            .expect("Add must succeed")
440            .text
441            .expect("Add must produce text");
442        assert!(
443            text.contains("crane.url = \"github:ipetkov/crane\""),
444            "new input must render as a flat url assignment; got:\n{text}",
445        );
446    }
447
448    #[test]
449    fn add_synthesizes_inputs_block_when_absent() {
450        // The first walk returns None when the source has no
451        // `inputs = { ... }` block to insert into. apply_add then flips
452        // `add_toplevel` and re-runs to synthesize one.
453        let flake = r#"{
454  outputs = { self, ... }: { };
455}"#;
456        let mut fe = FlakeEdit::from_text(flake).unwrap();
457        let change = Change::Add {
458            id: Some(crate::change::ChangeId::parse("nixpkgs").unwrap()),
459            uri: Some("github:nixos/nixpkgs".into()),
460            flake: true,
461        };
462        let text = fe
463            .apply_change(change)
464            .expect("Add must succeed")
465            .text
466            .expect("Add must produce text");
467        assert!(
468            text.contains("inputs.nixpkgs.url = \"github:nixos/nixpkgs\""),
469            "synthesized toplevel form must use flat url assignment; got:\n{text}",
470        );
471    }
472
473    #[test]
474    fn add_duplicate_returns_duplicate_input_error() {
475        let mut fe = FlakeEdit::from_text(flake_with_nixpkgs_and_crane()).unwrap();
476        let change = Change::Add {
477            id: Some(crate::change::ChangeId::parse("crane").unwrap()),
478            uri: Some("github:ipetkov/crane".into()),
479            flake: true,
480        };
481        let err = fe.apply_change(change).expect_err("duplicate must error");
482        assert!(
483            matches!(err, Error::DuplicateInput(ref id) if id == "crane"),
484            "expected DuplicateInput(\"crane\"), got: {err:?}",
485        );
486    }
487
488    #[test]
489    fn remove_strips_existing_input() {
490        let mut fe = FlakeEdit::from_text(flake_with_nixpkgs_and_crane()).unwrap();
491        let change = Change::Remove {
492            ids: vec![crate::change::ChangeId::parse("crane").unwrap()],
493        };
494        let text = fe
495            .apply_change(change)
496            .expect("Remove must succeed")
497            .text
498            .expect("Remove must produce text");
499        assert!(
500            !text.contains("crane"),
501            "removed id must not appear; got:\n{text}"
502        );
503        assert!(text.contains("nixpkgs"), "untouched id must remain");
504    }
505
506    #[test]
507    fn remove_scrubs_orphaned_follows_pointing_at_removed_input() {
508        // Removing a top-level input must also strip any sibling input's
509        // `follows = "<removed>"` declaration; apply_remove gates this
510        // scrub on `is_toplevel_remove`, so a depth-N remove must NOT
511        // trigger it.
512        let flake = r#"{
513  inputs = {
514    nixpkgs.url = "github:nixos/nixpkgs";
515    crane = {
516      url = "github:ipetkov/crane";
517      inputs.nixpkgs.follows = "nixpkgs";
518    };
519  };
520  outputs = { ... }: { };
521}"#;
522        let mut fe = FlakeEdit::from_text(flake).unwrap();
523        let change = Change::Remove {
524            ids: vec![crate::change::ChangeId::parse("nixpkgs").unwrap()],
525        };
526        let text = fe
527            .apply_change(change)
528            .expect("Remove must succeed")
529            .text
530            .expect("Remove must produce text");
531        assert!(
532            !text.contains("follows = \"nixpkgs\""),
533            "orphaned follows must be scrubbed; got:\n{text}",
534        );
535        assert!(text.contains("crane"), "sibling input must remain");
536    }
537
538    #[test]
539    fn change_uri_rewrites_existing_input() {
540        let mut fe = FlakeEdit::from_text(flake_with_nixpkgs_and_crane()).unwrap();
541        let change = Change::Change {
542            id: Some(crate::change::ChangeId::parse("crane").unwrap()),
543            uri: Some("github:ipetkov/crane/v0.20.0".into()),
544        };
545        let text = fe
546            .apply_change(change)
547            .expect("Change must succeed")
548            .text
549            .expect("Change must produce text");
550        assert!(
551            text.contains("github:ipetkov/crane/v0.20.0"),
552            "new uri must be present; got:\n{text}",
553        );
554    }
555
556    #[test]
557    fn change_uri_missing_input_returns_input_not_found() {
558        let mut fe = FlakeEdit::from_text(flake_with_nixpkgs_and_crane()).unwrap();
559        let change = Change::Change {
560            id: Some(crate::change::ChangeId::parse("does-not-exist").unwrap()),
561            uri: Some("github:owner/repo".into()),
562        };
563        let err = fe
564            .apply_change(change)
565            .expect_err("missing input must error");
566        assert!(
567            matches!(err, Error::InputNotFound(ref id) if id == "does-not-exist"),
568            "expected InputNotFound(\"does-not-exist\"), got: {err:?}",
569        );
570    }
571
572    #[test]
573    fn follows_missing_parent_returns_input_not_found() {
574        let mut fe = FlakeEdit::from_text(flake_with_nixpkgs_and_crane()).unwrap();
575        let change = Change::Follows {
576            input: crate::change::ChangeId::parse("ghost.nixpkgs").unwrap(),
577            target: crate::follows::AttrPath::parse("nixpkgs").unwrap(),
578        };
579        let err = fe
580            .apply_change(change)
581            .expect_err("missing parent must error");
582        assert!(
583            matches!(err, Error::InputNotFound(ref id) if id == "ghost"),
584            "expected InputNotFound(\"ghost\"), got: {err:?}",
585        );
586    }
587
588    #[test]
589    fn already_follows_is_noop() {
590        let flake = r#"{
591  inputs = {
592    nixpkgs.url = "github:nixos/nixpkgs";
593    crane = {
594      url = "github:ipetkov/crane";
595      inputs.nixpkgs.follows = "nixpkgs";
596    };
597  };
598  outputs = { ... }: { };
599}"#;
600        let mut fe = FlakeEdit::from_text(flake).unwrap();
601        let original = fe.source_text();
602        let change = Change::Follows {
603            input: crate::change::ChangeId::parse("crane.nixpkgs").unwrap(),
604            target: crate::follows::AttrPath::parse("nixpkgs").unwrap(),
605        };
606        let result = fe.apply_change(change).unwrap();
607        // Walker signals a no-op as either the unchanged text or `None`.
608        // Both are acceptable here.
609        if let Some(text) = result.text {
610            assert_eq!(text, original, "text should be unchanged");
611        }
612    }
613
614    #[test]
615    fn new_follows_succeeds() {
616        let flake = r#"{
617  inputs = {
618    nixpkgs.url = "github:nixos/nixpkgs";
619    crane = {
620      url = "github:ipetkov/crane";
621    };
622  };
623  outputs = { ... }: { };
624}"#;
625        let mut fe = FlakeEdit::from_text(flake).unwrap();
626        let change = Change::Follows {
627            input: crate::change::ChangeId::parse("crane.nixpkgs").unwrap(),
628            target: crate::follows::AttrPath::parse("nixpkgs").unwrap(),
629        };
630        let result = fe.apply_change(change);
631        assert!(result.is_ok(), "expected Ok, got: {:?}", result);
632        let text = result.unwrap().text.unwrap();
633        assert!(text.contains("inputs.nixpkgs.follows = \"nixpkgs\""));
634    }
635
636    #[test]
637    fn follows_target_with_dots_renders_as_flat_string() {
638        use crate::follows::{AttrPath, Segment};
639
640        let flake = r#"{
641  inputs = {
642    "ghc-8.6.5-iohk".url = "github:input-output-hk/ghc";
643    crane = {
644      url = "github:ipetkov/crane";
645    };
646  };
647  outputs = { ... }: { };
648}"#;
649        let mut fe = FlakeEdit::from_text(flake).unwrap();
650        let target_seg = Segment::from_unquoted("ghc-8.6.5-iohk").unwrap();
651        let change = Change::Follows {
652            input: crate::change::ChangeId::parse("crane.\"ghc-8.6.5-iohk\"").unwrap(),
653            target: AttrPath::new(target_seg),
654        };
655        let text = fe
656            .apply_change(change)
657            .expect("apply Change::Follows")
658            .text
659            .expect("walker must produce changed text");
660
661        let expected = "inputs.\"ghc-8.6.5-iohk\".follows = \"ghc-8.6.5-iohk\";";
662        assert!(
663            text.contains(expected),
664            "RHS must render as a flat Nix string, got:\n{text}",
665        );
666        assert!(
667            !text.contains(r#""ghc-8."6"."#),
668            "RHS must not contain segment-by-segment quoting, got:\n{text}",
669        );
670        assert!(
671            !text.contains(r#"= ""ghc-8"#),
672            "RHS must not double-quote the target, got:\n{text}",
673        );
674    }
675}