Skip to main content

flake_edit/
validate.rs

1//! Validation for Nix expressions.
2
3use std::collections::HashMap;
4use std::collections::hash_map::Entry;
5use std::fmt;
6
7use rnix::{Root, SyntaxKind, SyntaxNode, TextRange};
8
9/// Location information for error reporting (1-indexed).
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct Location {
12    pub line: usize,
13    pub column: usize,
14}
15
16impl fmt::Display for Location {
17    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
18        write!(f, "line {}, column {}", self.line, self.column)
19    }
20}
21
22/// Information about a duplicate attribute.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct DuplicateAttr {
25    /// The attribute path, e.g., "a.b.c" or "inputs.nixpkgs.url".
26    pub path: String,
27    /// Location of the first occurrence.
28    pub first: Location,
29    /// Location of the duplicate occurrence.
30    pub duplicate: Location,
31}
32
33impl fmt::Display for DuplicateAttr {
34    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35        write!(
36            f,
37            "duplicate attribute '{}' at {} (first defined at {})",
38            self.path, self.duplicate, self.first
39        )
40    }
41}
42
43/// Validation errors that can occur when parsing Nix expressions.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum ValidationError {
46    /// Syntax error from rnix parser.
47    ParseError { message: String, location: Location },
48    /// Duplicate attribute in an attribute set.
49    DuplicateAttribute(DuplicateAttr),
50}
51
52impl fmt::Display for ValidationError {
53    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54        match self {
55            ValidationError::ParseError { message, location } => {
56                write!(f, "parse error at {}: {}", location, message)
57            }
58            ValidationError::DuplicateAttribute(dup) => write!(f, "{}", dup),
59        }
60    }
61}
62
63/// Result of validation containing any errors found.
64#[derive(Debug, Default)]
65pub struct ValidationResult {
66    pub errors: Vec<ValidationError>,
67}
68
69impl ValidationResult {
70    pub fn is_ok(&self) -> bool {
71        self.errors.is_empty()
72    }
73
74    pub fn has_errors(&self) -> bool {
75        !self.errors.is_empty()
76    }
77}
78
79/// Validator for Nix expressions.
80pub struct Validator {
81    source: String,
82    /// Byte offsets where each line starts (for computing line/column).
83    line_starts: Vec<usize>,
84}
85
86/// Extract the full attribute path as a string, e.g., "a.b.c".
87fn extract_attrpath(attrpath: &SyntaxNode) -> String {
88    attrpath
89        .children()
90        .map(|child| {
91            let s = child.to_string();
92            // Unquote string attribute names: `"a"` -> `a`
93            if child.kind() == SyntaxKind::NODE_STRING {
94                s.trim_matches('"').to_string()
95            } else {
96                s
97            }
98        })
99        .collect::<Vec<_>>()
100        .join(".")
101}
102
103/// Check whether a `NODE_ATTRPATH_VALUE` node has a `NODE_ATTR_SET` as its value.
104fn value_is_attrset(node: &SyntaxNode) -> bool {
105    node.children()
106        .any(|c| c.kind() == SyntaxKind::NODE_ATTR_SET)
107}
108
109impl Validator {
110    /// Create a new validator for the given source.
111    pub fn new(source: &str) -> Self {
112        let line_starts = Self::compute_line_starts(source);
113        Self {
114            source: source.to_string(),
115            line_starts,
116        }
117    }
118
119    /// Compute byte offsets for the start of each line.
120    fn compute_line_starts(source: &str) -> Vec<usize> {
121        let mut starts = vec![0];
122        for (i, c) in source.char_indices() {
123            if c == '\n' {
124                starts.push(i + 1);
125            }
126        }
127        starts
128    }
129
130    /// Convert a TextRange to a Location (using the start position).
131    fn range_to_location(&self, range: TextRange) -> Location {
132        self.offset_to_location(range.start().into())
133    }
134
135    /// Convert a byte offset to line and column (1-indexed).
136    fn offset_to_location(&self, offset: usize) -> Location {
137        let line = self
138            .line_starts
139            .iter()
140            .rposition(|&start| start <= offset)
141            .unwrap_or(0);
142        let column = offset - self.line_starts[line];
143        Location {
144            line: line + 1,
145            column: column + 1,
146        }
147    }
148
149    /// Validate the source and return any errors found.
150    pub fn validate(&self) -> ValidationResult {
151        let root = Root::parse(&self.source);
152        let mut errors = Vec::new();
153
154        // Collect rnix parse errors
155        for error in root.errors() {
156            let location = self.parse_error_location(error);
157            errors.push(ValidationError::ParseError {
158                message: error.to_string(),
159                location,
160            });
161        }
162
163        // Check for duplicate attributes
164        let syntax = root.syntax();
165        self.check_node(&syntax, &mut errors);
166
167        ValidationResult { errors }
168    }
169
170    /// Extract location from an rnix ParseError.
171    fn parse_error_location(&self, error: &rnix::ParseError) -> Location {
172        use rnix::ParseError::*;
173        match error {
174            Unexpected(r)
175            | UnexpectedExtra(r)
176            | UnexpectedWanted(_, r, _)
177            | UnexpectedDoubleBind(r)
178            | DuplicatedArgs(r, _) => self.range_to_location(*r),
179            UnexpectedEOF | UnexpectedEOFWanted(_) | RecursionLimitExceeded | _ => Location {
180                line: self.line_starts.len(),
181                column: 1,
182            },
183        }
184    }
185
186    /// Recursively check a node and its descendants for duplicate attributes.
187    fn check_node(&self, node: &SyntaxNode, errors: &mut Vec<ValidationError>) {
188        if node.kind() == SyntaxKind::NODE_ATTR_SET {
189            self.check_attr_set(node, errors);
190        }
191
192        for child in node.children() {
193            self.check_node(&child, errors);
194        }
195    }
196
197    /// Check an attribute set for duplicate attributes.
198    ///
199    /// Nix allows duplicate attribute names when both values are attribute sets
200    /// (they get merged). For example:
201    /// ```nix
202    /// {
203    ///   inputs = { nixpkgs.url = "..."; };
204    ///   inputs = { flake-utils.url = "..."; };
205    /// }
206    /// ```
207    /// is equivalent to a single `inputs` with both entries. We allow this but
208    /// still check the merged contents for true conflicts.
209    fn check_attr_set(&self, attr_set: &SyntaxNode, errors: &mut Vec<ValidationError>) {
210        // Track first occurrence: path -> (location, is_attrset, node)
211        let mut seen: HashMap<String, (Location, bool, SyntaxNode)> = HashMap::new();
212        // Track all attrset-valued nodes for a given path so we can cross-check
213        let mut merged_attrsets: HashMap<String, Vec<SyntaxNode>> = HashMap::new();
214
215        for child in attr_set.children() {
216            if child.kind() == SyntaxKind::NODE_ATTRPATH_VALUE
217                && let Some(attrpath) = child
218                    .children()
219                    .find(|c| c.kind() == SyntaxKind::NODE_ATTRPATH)
220            {
221                let path = extract_attrpath(&attrpath);
222                let location = self.range_to_location(attrpath.text_range());
223                let is_attrset = value_is_attrset(&child);
224
225                match seen.entry(path.clone()) {
226                    Entry::Occupied(entry) => {
227                        let (ref first_loc, first_is_attrset, _) = *entry.get();
228                        if first_is_attrset && is_attrset {
229                            // Both are attrsets — valid Nix merge. Collect for
230                            // cross-checking.
231                            merged_attrsets.entry(path).or_default().push(child.clone());
232                        } else {
233                            // Not both attrsets — true duplicate conflict.
234                            errors.push(ValidationError::DuplicateAttribute(DuplicateAttr {
235                                path: entry.key().clone(),
236                                first: first_loc.clone(),
237                                duplicate: location,
238                            }));
239                        }
240                    }
241                    Entry::Vacant(entry) => {
242                        if is_attrset {
243                            merged_attrsets.entry(path).or_default().push(child.clone());
244                        }
245                        entry.insert((location, is_attrset, child.clone()));
246                    }
247                }
248            }
249        }
250
251        // Cross-check merged attrsets for duplicate attrs across blocks.
252        for nodes in merged_attrsets.values() {
253            if nodes.len() < 2 {
254                continue;
255            }
256            // Collect all attrs across all blocks and check for duplicates.
257            let mut cross_seen: HashMap<String, Location> = HashMap::new();
258            for node in nodes {
259                // Find the NODE_ATTR_SET child to iterate its attrs.
260                for attrset_child in node.children() {
261                    if attrset_child.kind() != SyntaxKind::NODE_ATTR_SET {
262                        continue;
263                    }
264                    for inner in attrset_child.children() {
265                        if inner.kind() == SyntaxKind::NODE_ATTRPATH_VALUE
266                            && let Some(inner_path_node) = inner
267                                .children()
268                                .find(|c| c.kind() == SyntaxKind::NODE_ATTRPATH)
269                        {
270                            let inner_path = extract_attrpath(&inner_path_node);
271                            let inner_loc = self.range_to_location(inner_path_node.text_range());
272
273                            match cross_seen.entry(inner_path) {
274                                Entry::Occupied(e) => {
275                                    errors.push(ValidationError::DuplicateAttribute(
276                                        DuplicateAttr {
277                                            path: e.key().clone(),
278                                            first: e.get().clone(),
279                                            duplicate: inner_loc,
280                                        },
281                                    ));
282                                }
283                                Entry::Vacant(e) => {
284                                    e.insert(inner_loc);
285                                }
286                            }
287                        }
288                    }
289                }
290            }
291        }
292    }
293}
294
295/// Convenience function to validate source and return errors.
296pub fn validate(source: &str) -> ValidationResult {
297    Validator::new(source).validate()
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303
304    fn expect_duplicate(err: &ValidationError) -> &DuplicateAttr {
305        match err {
306            ValidationError::DuplicateAttribute(dup) => dup,
307            ValidationError::ParseError { .. } => {
308                panic!("expected DuplicateAttribute, got ParseError")
309            }
310        }
311    }
312
313    #[test]
314    fn simple_duplicate() {
315        let source = "{ a = 1; a = 2; }";
316        let result = validate(source);
317        assert!(result.has_errors());
318        assert_eq!(result.errors.len(), 1);
319
320        let dup = expect_duplicate(&result.errors[0]);
321        assert_eq!(dup.path, "a");
322        assert_eq!(dup.first.line, 1);
323        assert_eq!(dup.first.column, 3);
324        assert_eq!(dup.duplicate.line, 1);
325        assert_eq!(dup.duplicate.column, 10);
326    }
327
328    #[test]
329    fn nested_path_duplicate() {
330        let source = "{ a.b.c = 1; a.b.c = 2; }";
331        let result = validate(source);
332        assert!(result.has_errors());
333        assert_eq!(result.errors.len(), 1);
334
335        let dup = expect_duplicate(&result.errors[0]);
336        assert_eq!(dup.path, "a.b.c");
337    }
338
339    #[test]
340    fn different_paths_valid() {
341        let source = "{ a.b = 1; a.c = 2; }";
342        let result = validate(source);
343        assert!(result.is_ok());
344    }
345
346    #[test]
347    fn flake_style_duplicate() {
348        let source = r#"{ inputs.nixpkgs.url = "github:nixos/nixpkgs"; inputs.nixpkgs.url = "github:nixos/nixpkgs/unstable"; }"#;
349        let result = validate(source);
350        assert!(result.has_errors());
351        assert_eq!(result.errors.len(), 1);
352
353        let dup = expect_duplicate(&result.errors[0]);
354        assert_eq!(dup.path, "inputs.nixpkgs.url");
355    }
356
357    #[test]
358    fn quoted_attribute_duplicate() {
359        let source = r#"{ "a" = 1; a = 2; }"#;
360        let result = validate(source);
361        assert!(result.has_errors());
362        assert_eq!(result.errors.len(), 1);
363
364        let dup = expect_duplicate(&result.errors[0]);
365        assert_eq!(dup.path, "a");
366    }
367
368    #[test]
369    fn nested_attr_set_duplicate() {
370        let source = "{ outer = { inner = 1; inner = 2; }; }";
371        let result = validate(source);
372        assert!(result.has_errors());
373        assert_eq!(result.errors.len(), 1);
374
375        let dup = expect_duplicate(&result.errors[0]);
376        assert_eq!(dup.path, "inner");
377    }
378
379    #[test]
380    fn multiple_duplicates() {
381        let source = "{ a = 1; a = 2; b = 3; b = 4; }";
382        let result = validate(source);
383        assert!(result.has_errors());
384        assert_eq!(result.errors.len(), 2);
385    }
386
387    #[test]
388    fn multiline_flake() {
389        let source = r#"{
390  inputs.nixpkgs.url = "github:nixos/nixpkgs";
391  inputs.nixpkgs.url = "github:nixos/nixpkgs/unstable";
392  outputs = { ... }: { };
393}"#;
394        let result = validate(source);
395        assert!(result.has_errors());
396        assert_eq!(result.errors.len(), 1);
397
398        let dup = expect_duplicate(&result.errors[0]);
399        assert_eq!(dup.path, "inputs.nixpkgs.url");
400        assert_eq!(dup.first.line, 2);
401        assert_eq!(dup.duplicate.line, 3);
402    }
403
404    #[test]
405    fn valid_flake() {
406        let source = r#"{
407  inputs.nixpkgs.url = "github:nixos/nixpkgs";
408  inputs.flake-utils.url = "github:numtide/flake-utils";
409  outputs = { self, nixpkgs, flake-utils }: { };
410}"#;
411        let result = validate(source);
412        assert!(result.is_ok());
413    }
414
415    #[test]
416    fn empty_attr_set() {
417        let source = "{ }";
418        let result = validate(source);
419        assert!(result.is_ok());
420    }
421
422    #[test]
423    fn single_attribute() {
424        let source = "{ a = 1; }";
425        let result = validate(source);
426        assert!(result.is_ok());
427    }
428
429    #[test]
430    fn parse_error_missing_semicolon() {
431        let source = "{ a = 1 }";
432        let result = validate(source);
433        assert!(result.has_errors());
434        assert!(matches!(
435            &result.errors[0],
436            ValidationError::ParseError { .. }
437        ));
438    }
439
440    #[test]
441    fn parse_error_unclosed_brace() {
442        let source = "{ a = 1;";
443        let result = validate(source);
444        assert!(result.has_errors());
445        assert!(matches!(
446            &result.errors[0],
447            ValidationError::ParseError { .. }
448        ));
449    }
450
451    #[test]
452    fn mergeable_attrsets_valid() {
453        // Two `inputs = { }` blocks with distinct attrs — valid Nix merge
454        let source = r#"{
455  inputs = {
456    nixpkgs.url = "github:NixOS/nixpkgs";
457  };
458  inputs = {
459    flake-utils.url = "github:numtide/flake-utils";
460  };
461}"#;
462        let result = validate(source);
463        assert!(
464            result.is_ok(),
465            "expected no errors, got: {:?}",
466            result.errors
467        );
468    }
469
470    #[test]
471    fn mergeable_attrsets_with_comments() {
472        // autofirma-nix pattern: comment-separated input groups
473        let source = r#"{
474  # Common inputs
475  inputs = {
476    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
477    home-manager.url = "github:nix-community/home-manager";
478  };
479
480  # Autofirma sources
481  inputs = {
482    jmulticard-src = {
483      url = "github:ctt-gob-es/jmulticard/v2.0";
484      flake = false;
485    };
486  };
487
488  outputs = { self, nixpkgs, ... }: { };
489}"#;
490        let result = validate(source);
491        assert!(
492            result.is_ok(),
493            "expected no errors, got: {:?}",
494            result.errors
495        );
496    }
497
498    #[test]
499    fn mergeable_attrsets_cross_duplicate() {
500        // Same attr in two merged blocks — true conflict
501        let source = r#"{
502  inputs = {
503    nixpkgs.url = "github:NixOS/nixpkgs";
504  };
505  inputs = {
506    nixpkgs.url = "github:NixOS/nixpkgs/unstable";
507  };
508}"#;
509        let result = validate(source);
510        assert!(result.has_errors());
511        assert_eq!(result.errors.len(), 1);
512
513        let dup = expect_duplicate(&result.errors[0]);
514        assert_eq!(dup.path, "nixpkgs.url");
515    }
516
517    #[test]
518    fn non_attrset_duplicate_still_errors() {
519        // One is attrset, other is not — true conflict
520        let source = r#"{ a = { x = 1; }; a = 2; }"#;
521        let result = validate(source);
522        assert!(result.has_errors());
523        assert_eq!(result.errors.len(), 1);
524
525        let dup = expect_duplicate(&result.errors[0]);
526        assert_eq!(dup.path, "a");
527    }
528
529    #[test]
530    fn three_mergeable_attrsets() {
531        let source = r#"{
532  inputs = { a.url = "a"; };
533  inputs = { b.url = "b"; };
534  inputs = { c.url = "c"; };
535}"#;
536        let result = validate(source);
537        assert!(
538            result.is_ok(),
539            "expected no errors, got: {:?}",
540            result.errors
541        );
542    }
543}