Skip to main content

flake_edit/
validate.rs

1//! Validation for Nix flake expressions.
2//!
3//! - [`error`]: [`ValidationError`], [`Severity`], [`Location`].
4//! - `syntax` (private): rnix parse errors and duplicate-attribute detection.
5//! - `follows` (crate-private): cycle, stale, target, contradiction, and depth
6//!   lints.
7//!
8//! [`validate`] runs the syntax-level lints. [`validate_full`] adds the
9//! follows-graph lints that need a parsed [`InputMap`] and an optional
10//! [`FlakeLock`].
11
12pub mod error;
13pub(crate) mod follows;
14mod syntax;
15
16pub use error::{DuplicateAttr, Location, Severity, ValidationError, ValidationResult};
17
18pub(crate) use syntax::ParsedSource;
19
20use crate::edit::InputMap;
21use crate::follows::{DEFAULT_MAX_DEPTH, FollowsGraph};
22use crate::lock::{FlakeLock, NestedInput};
23
24/// Run the syntax-level lints over `source`: parse errors, duplicate
25/// attributes, and the always-on declared-cycle check.
26pub fn validate(source: &str) -> ValidationResult {
27    let parsed = ParsedSource::new(source);
28    validate_parsed(&parsed)
29}
30
31/// [`validate`] for callers that already hold a [`ParsedSource`], so the
32/// rnix parse is shared with [`crate::walk::Walker`] construction.
33pub(crate) fn validate_parsed(parsed: &ParsedSource) -> ValidationResult {
34    let mut errors: Vec<ValidationError> = Vec::new();
35    syntax::collect_with_parsed(parsed, &mut errors);
36    if errors.is_empty() {
37        let mut walker = crate::walk::Walker::from_root(parsed.syntax.clone());
38        if walker.walk(&crate::change::Change::None).is_ok() {
39            let graph = crate::follows::FollowsGraph::from_declared(&walker.inputs);
40            let offset_to_location = |offset: usize| parsed.line_map.offset_to_location(offset);
41            errors.extend(follows::lint_follows_cycle(&graph, &offset_to_location));
42        }
43    }
44    ValidationResult {
45        errors,
46        warnings: Vec::new(),
47    }
48}
49
50/// Run syntax checks plus every follows-graph lint.
51///
52/// Walks `flake.lock` once via [`FlakeLock::nested_inputs`], builds the lock
53/// graph from that single walk, and hands both to
54/// [`validate_full_with_lock_graph`].
55pub fn validate_full(
56    source: &str,
57    inputs: &InputMap,
58    lock: Option<&FlakeLock>,
59) -> ValidationResult {
60    let parsed = ParsedSource::new(source);
61    let nested_inputs = lock.map(FlakeLock::nested_inputs);
62    let lock_graph = nested_inputs
63        .as_deref()
64        .map(FollowsGraph::from_nested_inputs);
65    validate_full_with_lock_graph(
66        &parsed,
67        inputs,
68        lock_graph.as_ref(),
69        nested_inputs.as_deref().unwrap_or(&[]),
70    )
71}
72
73/// Like [`validate_full`] but skips the lock-drift lints (`lint_follows_stale`
74/// and `lint_follows_stale_lock`) that compare declared edges in `flake.nix`
75/// against `flake.lock`.
76///
77/// For speculative validation during a multi-step apply. The lockfile cannot
78/// reflect mid-batch text edits, so a freshly-added follows always looks
79/// stale relative to the on-disk lock. Running lock-drift lints there would
80/// flag every in-progress edit as drift.
81pub fn validate_speculative(
82    source: &str,
83    inputs: &InputMap,
84    lock: Option<&FlakeLock>,
85) -> ValidationResult {
86    let parsed = ParsedSource::new(source);
87    let lock_graph = lock.map(FollowsGraph::from_lock);
88    validate_speculative_parsed(&parsed, inputs, lock_graph.as_ref())
89}
90
91/// [`validate_speculative`] for callers that already hold a [`ParsedSource`]
92/// and a pre-built [`FollowsGraph`] of the lockfile (typically from
93/// [`FollowsGraph::from_lock`]). Pass `lock_graph = None` to validate against
94/// declared edges only.
95///
96/// Skips the duplicate-attribute lints. The apply loop's
97/// [`crate::change::Change`] variants cannot introduce duplicates the source
98/// did not already carry: `Add` rejects existing ids, `Follows` mutates in
99/// place, `Remove` only deletes. [`validate_full`] runs once before the batch
100/// and covers the original duplicate state.
101pub(crate) fn validate_speculative_parsed(
102    parsed: &ParsedSource,
103    inputs: &InputMap,
104    lock_graph: Option<&FollowsGraph>,
105) -> ValidationResult {
106    let mut errors: Vec<ValidationError> = parsed.parse_errors.to_vec();
107    let mut warnings: Vec<ValidationError> = Vec::new();
108    let graph = follows::build_graph_with_lock_graph(inputs, lock_graph, DEFAULT_MAX_DEPTH);
109    run_follows_lints(parsed, inputs, &graph, None, &mut errors, &mut warnings);
110    ValidationResult { errors, warnings }
111}
112
113/// [`validate_full`] for callers that already hold a [`ParsedSource`], a
114/// lockfile-derived [`FollowsGraph`] (from [`FollowsGraph::from_lock`] or
115/// [`FollowsGraph::from_nested_inputs`]), and the lockfile's nested-input
116/// set. Reuses all three instead of re-walking `flake.lock`.
117///
118/// `lock_graph = Some(..)` enables the lock-drift lints (`lint_follows_stale`
119/// and `lint_follows_stale_lock`), which read `nested_inputs`. `None` skips
120/// them and `nested_inputs` is ignored.
121pub(crate) fn validate_full_with_lock_graph(
122    parsed: &ParsedSource,
123    inputs: &InputMap,
124    lock_graph: Option<&FollowsGraph>,
125    nested_inputs: &[NestedInput],
126) -> ValidationResult {
127    let mut errors: Vec<ValidationError> = Vec::new();
128    let mut warnings: Vec<ValidationError> = Vec::new();
129    syntax::collect_with_parsed(parsed, &mut errors);
130    let graph = follows::build_graph_with_lock_graph(inputs, lock_graph, DEFAULT_MAX_DEPTH);
131    let nested = lock_graph.is_some().then_some(nested_inputs);
132    run_follows_lints(parsed, inputs, &graph, nested, &mut errors, &mut warnings);
133    ValidationResult { errors, warnings }
134}
135
136/// Run every follows-graph lint and route results into `errors`/`warnings` by
137/// severity. `nested_inputs` enables the lock-drift lints (stale and
138/// stale-lock); pass `None` to skip them.
139fn run_follows_lints(
140    parsed: &ParsedSource,
141    inputs: &InputMap,
142    graph: &FollowsGraph,
143    nested_inputs: Option<&[NestedInput]>,
144    errors: &mut Vec<ValidationError>,
145    warnings: &mut Vec<ValidationError>,
146) {
147    let offset_to_location = |offset: usize| parsed.line_map.offset_to_location(offset);
148
149    let mut candidates: Vec<ValidationError> = Vec::new();
150    candidates.extend(follows::lint_follows_cycle(graph, &offset_to_location));
151    if let Some(nested) = nested_inputs {
152        candidates.extend(follows::lint_follows_stale(graph, &offset_to_location));
153        candidates.extend(follows::lint_follows_stale_lock(
154            graph,
155            nested,
156            &offset_to_location,
157        ));
158    }
159    let top_level = follows::top_level_names(inputs);
160    candidates.extend(follows::lint_follows_target_not_toplevel(
161        graph,
162        &top_level,
163        &offset_to_location,
164    ));
165    candidates.extend(follows::lint_follows_contradiction(
166        graph,
167        &offset_to_location,
168    ));
169    candidates.extend(follows::lint_follows_depth_exceeded(
170        graph,
171        DEFAULT_MAX_DEPTH,
172        &offset_to_location,
173    ));
174
175    for err in candidates {
176        match err.severity() {
177            Severity::Warning => warnings.push(err),
178            Severity::Error => errors.push(err),
179        }
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use crate::edit::InputMap;
187    use crate::follows::{AttrPath, Segment};
188    use crate::input::{Follows, Input, Range};
189    use crate::validate::error::DuplicateAttr;
190
191    fn expect_duplicate(err: &ValidationError) -> &DuplicateAttr {
192        match err {
193            ValidationError::DuplicateAttribute(dup) => dup,
194            other => panic!("expected DuplicateAttribute, got {other:?}"),
195        }
196    }
197
198    #[test]
199    fn simple_duplicate() {
200        let source = "{ a = 1; a = 2; }";
201        let result = validate(source);
202        assert!(result.has_errors());
203        assert_eq!(result.errors.len(), 1);
204
205        let dup = expect_duplicate(&result.errors[0]);
206        assert_eq!(dup.path, "a");
207        assert_eq!(dup.first.line, 1);
208        assert_eq!(dup.first.column, 3);
209        assert_eq!(dup.duplicate.line, 1);
210        assert_eq!(dup.duplicate.column, 10);
211    }
212
213    #[test]
214    fn nested_path_duplicate() {
215        let source = "{ a.b.c = 1; a.b.c = 2; }";
216        let result = validate(source);
217        assert!(result.has_errors());
218        assert_eq!(result.errors.len(), 1);
219
220        let dup = expect_duplicate(&result.errors[0]);
221        assert_eq!(dup.path, "a.b.c");
222    }
223
224    #[test]
225    fn different_paths_valid() {
226        let source = "{ a.b = 1; a.c = 2; }";
227        let result = validate(source);
228        assert!(result.is_ok());
229    }
230
231    #[test]
232    fn flake_style_duplicate() {
233        let source = r#"{ inputs.nixpkgs.url = "github:nixos/nixpkgs"; inputs.nixpkgs.url = "github:nixos/nixpkgs/unstable"; }"#;
234        let result = validate(source);
235        assert!(result.has_errors());
236        assert_eq!(result.errors.len(), 1);
237
238        let dup = expect_duplicate(&result.errors[0]);
239        assert_eq!(dup.path, "inputs.nixpkgs.url");
240    }
241
242    #[test]
243    fn quoted_attribute_duplicate() {
244        let source = r#"{ "a" = 1; a = 2; }"#;
245        let result = validate(source);
246        assert!(result.has_errors());
247        assert_eq!(result.errors.len(), 1);
248
249        let dup = expect_duplicate(&result.errors[0]);
250        assert_eq!(dup.path, "a");
251    }
252
253    #[test]
254    fn nested_attr_set_duplicate() {
255        let source = "{ outer = { inner = 1; inner = 2; }; }";
256        let result = validate(source);
257        assert!(result.has_errors());
258        assert_eq!(result.errors.len(), 1);
259
260        let dup = expect_duplicate(&result.errors[0]);
261        assert_eq!(dup.path, "inner");
262    }
263
264    #[test]
265    fn multiple_duplicates() {
266        let source = "{ a = 1; a = 2; b = 3; b = 4; }";
267        let result = validate(source);
268        assert!(result.has_errors());
269        assert_eq!(result.errors.len(), 2);
270    }
271
272    #[test]
273    fn multiline_flake() {
274        let source = r#"{
275  inputs.nixpkgs.url = "github:nixos/nixpkgs";
276  inputs.nixpkgs.url = "github:nixos/nixpkgs/unstable";
277  outputs = { ... }: { };
278}"#;
279        let result = validate(source);
280        assert!(result.has_errors());
281        assert_eq!(result.errors.len(), 1);
282
283        let dup = expect_duplicate(&result.errors[0]);
284        assert_eq!(dup.path, "inputs.nixpkgs.url");
285        assert_eq!(dup.first.line, 2);
286        assert_eq!(dup.duplicate.line, 3);
287    }
288
289    #[test]
290    fn valid_flake() {
291        let source = r#"{
292  inputs.nixpkgs.url = "github:nixos/nixpkgs";
293  inputs.flake-utils.url = "github:numtide/flake-utils";
294  outputs = { self, nixpkgs, flake-utils }: { };
295}"#;
296        let result = validate(source);
297        assert!(result.is_ok());
298    }
299
300    #[test]
301    fn empty_attr_set() {
302        let source = "{ }";
303        let result = validate(source);
304        assert!(result.is_ok());
305    }
306
307    #[test]
308    fn single_attribute() {
309        let source = "{ a = 1; }";
310        let result = validate(source);
311        assert!(result.is_ok());
312    }
313
314    #[test]
315    fn parse_error_missing_semicolon() {
316        let source = "{ a = 1 }";
317        let result = validate(source);
318        assert!(result.has_errors());
319        assert!(matches!(
320            &result.errors[0],
321            ValidationError::ParseError { .. }
322        ));
323    }
324
325    #[test]
326    fn parse_error_unclosed_brace() {
327        let source = "{ a = 1;";
328        let result = validate(source);
329        assert!(result.has_errors());
330        assert!(matches!(
331            &result.errors[0],
332            ValidationError::ParseError { .. }
333        ));
334    }
335
336    #[test]
337    fn mergeable_attrsets_valid() {
338        let source = r#"{
339  inputs = {
340    nixpkgs.url = "github:NixOS/nixpkgs";
341  };
342  inputs = {
343    flake-utils.url = "github:numtide/flake-utils";
344  };
345}"#;
346        let result = validate(source);
347        assert!(
348            result.is_ok(),
349            "expected no errors, got: {:?}",
350            result.errors
351        );
352    }
353
354    #[test]
355    fn mergeable_attrsets_with_comments() {
356        // autofirma-nix pattern: comment-separated input groups
357        let source = r#"{
358  # Common inputs
359  inputs = {
360    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
361    home-manager.url = "github:nix-community/home-manager";
362  };
363
364  # Autofirma sources
365  inputs = {
366    jmulticard-src = {
367      url = "github:ctt-gob-es/jmulticard/v2.0";
368      flake = false;
369    };
370  };
371
372  outputs = { self, nixpkgs, ... }: { };
373}"#;
374        let result = validate(source);
375        assert!(
376            result.is_ok(),
377            "expected no errors, got: {:?}",
378            result.errors
379        );
380    }
381
382    #[test]
383    fn mergeable_attrsets_cross_duplicate() {
384        let source = r#"{
385  inputs = {
386    nixpkgs.url = "github:NixOS/nixpkgs";
387  };
388  inputs = {
389    nixpkgs.url = "github:NixOS/nixpkgs/unstable";
390  };
391}"#;
392        let result = validate(source);
393        assert!(result.has_errors());
394        assert_eq!(result.errors.len(), 1);
395
396        let dup = expect_duplicate(&result.errors[0]);
397        assert_eq!(dup.path, "nixpkgs.url");
398    }
399
400    #[test]
401    fn non_attrset_duplicate_still_errors() {
402        let source = r#"{ a = { x = 1; }; a = 2; }"#;
403        let result = validate(source);
404        assert!(result.has_errors());
405        assert_eq!(result.errors.len(), 1);
406
407        let dup = expect_duplicate(&result.errors[0]);
408        assert_eq!(dup.path, "a");
409    }
410
411    #[test]
412    fn follows_cycle_self_edge_lints() {
413        let source = r#"{
414  inputs.foo = {
415    url = "github:owner/foo";
416    inputs.foo.follows = "foo/foo";
417  };
418  outputs = { ... }: { };
419}"#;
420        let result = validate(source);
421        assert!(
422            result
423                .errors
424                .iter()
425                .any(|e| matches!(e, ValidationError::FollowsCycle { .. })),
426            "expected FollowsCycle, got: {:?}",
427            result.errors,
428        );
429    }
430
431    #[test]
432    fn three_mergeable_attrsets() {
433        let source = r#"{
434  inputs = { a.url = "a"; };
435  inputs = { b.url = "b"; };
436  inputs = { c.url = "c"; };
437}"#;
438        let result = validate(source);
439        assert!(
440            result.is_ok(),
441            "expected no errors, got: {:?}",
442            result.errors
443        );
444    }
445
446    fn seg(s: &str) -> Segment {
447        Segment::from_unquoted(s).unwrap()
448    }
449
450    fn path(s: &str) -> AttrPath {
451        AttrPath::parse(s).unwrap()
452    }
453
454    fn declared_input(id: &str, follows: &[(&str, &str)]) -> Input {
455        let mut input = Input::new(seg(id));
456        for (parent, target) in follows {
457            input.follows.push(Follows::Indirect {
458                path: AttrPath::new(seg(parent)),
459                target: Some(path(target)),
460            });
461        }
462        input.range = Range { start: 1, end: 2 };
463        input
464    }
465
466    fn make_inputs(items: Vec<Input>) -> InputMap {
467        let mut map = InputMap::new();
468        for input in items {
469            map.insert(input.id().as_str().to_string(), input);
470        }
471        map
472    }
473
474    #[test]
475    fn validate_full_emits_target_not_toplevel_by_default() {
476        let inputs = make_inputs(vec![declared_input("a", &[("b", "missing")])]);
477        let result = validate_full("{}", &inputs, None);
478        assert!(
479            result
480                .errors
481                .iter()
482                .any(|e| matches!(e, ValidationError::FollowsTargetNotToplevel { .. })),
483            "expected target-not-toplevel error, got: {:?}",
484            result.errors,
485        );
486    }
487
488    #[test]
489    fn validate_full_separates_warnings_from_errors() {
490        // Stale follows is a warning, target-not-toplevel is an error, and
491        // both can fire on the same input.
492        let inputs = make_inputs(vec![declared_input("a", &[("b", "missing")])]);
493        let lock_text = r#"{
494  "nodes": {
495    "a": {
496      "locked": { "lastModified": 1, "narHash": "", "owner": "o", "repo": "r", "rev": "abc", "type": "github" },
497      "original": { "owner": "o", "repo": "r", "type": "github" }
498    },
499    "root": { "inputs": { "a": "a" } }
500  },
501  "root": "root",
502  "version": 7
503}"#;
504        let lock = FlakeLock::read_from_str(lock_text).unwrap();
505        let result = validate_full("{}", &inputs, Some(&lock));
506        assert!(
507            result
508                .errors
509                .iter()
510                .any(|e| matches!(e, ValidationError::FollowsTargetNotToplevel { .. })),
511        );
512        assert!(
513            result
514                .warnings
515                .iter()
516                .any(|e| matches!(e, ValidationError::FollowsStale { .. })),
517            "expected at least one stale warning, got warnings: {:?}",
518            result.warnings,
519        );
520    }
521}