Skip to main content

flake_edit/
edit.rs

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