Skip to main content

normalize_manifest/
flake.rs

1//! Heuristic parser for `flake.nix` files (Nix).
2//!
3//! Full Nix expression evaluation is not feasible. We use line-pattern matching
4//! to extract `inputs.<name>.url = "..."` declarations.
5//!
6//! Version requirements are NOT available — only dep names and their source URLs.
7//! `version_req` is always `None`; the URL is stored in a separate `url` field
8//! when needed. For `DeclaredDep`, the URL is omitted (it doesn't fit the schema).
9
10use crate::{DeclaredDep, DepKind, ManifestError, ManifestParser, ParsedManifest};
11
12/// Heuristic parser for `flake.nix` files.
13pub struct FlakeParser;
14
15impl ManifestParser for FlakeParser {
16    fn filename(&self) -> &'static str {
17        "flake.nix"
18    }
19
20    fn parse(&self, content: &str) -> Result<ParsedManifest, ManifestError> {
21        let mut deps = Vec::new();
22        let mut seen = std::collections::HashSet::new();
23        let mut in_inputs_block = false;
24        // Depth of `{` braces seen after entering the inputs block.
25        // We exit when this returns to 0 (the `}` closing the inputs block itself).
26        let mut inputs_depth: i32 = 0;
27
28        for line in content.lines() {
29            let trimmed = line.trim();
30            if trimmed.is_empty() || trimmed.starts_with('#') {
31                continue;
32            }
33
34            // Detect `inputs = {` block
35            if !in_inputs_block
36                && trimmed.starts_with("inputs")
37                && trimmed.contains('=')
38                && trimmed.contains('{')
39            {
40                in_inputs_block = true;
41                inputs_depth = 1; // the opening `{` of inputs = { ... }
42                continue;
43            }
44
45            if in_inputs_block {
46                // Track inner brace depth so we don't exit on sub-object `};`
47                for ch in trimmed.chars() {
48                    match ch {
49                        '{' => inputs_depth += 1,
50                        '}' => {
51                            inputs_depth -= 1;
52                            if inputs_depth <= 0 {
53                                in_inputs_block = false;
54                                break;
55                            }
56                        }
57                        _ => {}
58                    }
59                }
60                if !in_inputs_block {
61                    continue;
62                }
63            }
64
65            // Two patterns to find input names:
66            //
67            // 1. `inputs.<name>.url = "..."` (flat / inline with outputs attribute set)
68            // 2. Inside `inputs = { ... }`: `<name>.url = "..."` or `<name> = { url = ...; }`
69            let input_name = if let Some(rest) = trimmed.strip_prefix("inputs.") {
70                // Pattern 1
71                let name_end = rest.find(['.', ' ', '=', '{']).unwrap_or(rest.len());
72                let n = rest[..name_end].trim().to_string();
73                if rest.contains(".follows") {
74                    continue; // skip follows declarations
75                }
76                n
77            } else if in_inputs_block {
78                // Pattern 2 — line inside inputs block like `nixpkgs.url = "..."` or `crane = {`
79                // Skip lines that are clearly not input names (opening/closing braces, etc.)
80                if trimmed == "{"
81                    || trimmed == "};"
82                    || trimmed == "}"
83                    || trimmed.starts_with("url")
84                    || trimmed.starts_with("inputs.")
85                    || trimmed.starts_with("description")
86                {
87                    continue;
88                }
89                // Extract name: first identifier before `.` or `=` or `{` or space
90                let name_end = trimmed.find(['.', ' ', '=', '{']).unwrap_or(trimmed.len());
91                let n = trimmed[..name_end].trim().to_string();
92                // Skip `.follows` lines
93                if trimmed.contains(".follows") {
94                    continue;
95                }
96                n
97            } else {
98                continue;
99            };
100
101            if input_name.is_empty() || input_name == "nixpkgs" {
102                continue;
103            }
104
105            if seen.insert(input_name.clone()) {
106                deps.push(DeclaredDep {
107                    name: input_name,
108                    version_req: None, // Nix flakes don't have semver constraints
109                    kind: DepKind::Normal,
110                });
111            }
112        }
113
114        Ok(ParsedManifest {
115            ecosystem: "nix",
116            name: None,
117            version: None,
118            dependencies: deps,
119        })
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    use crate::ManifestParser;
127
128    #[test]
129    fn test_parse_flake_nix() {
130        let content = r#"{
131  description = "My Nix flake";
132
133  inputs = {
134    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
135    flake-utils.url = "github:numtide/flake-utils";
136    rust-overlay = {
137      url = "github:oxalica/rust-overlay";
138      inputs.nixpkgs.follows = "nixpkgs";
139    };
140    crane.url = "github:ipetkov/crane";
141  };
142
143  outputs = { self, nixpkgs, flake-utils, rust-overlay, crane, ... }: {};
144}
145"#;
146        let m = FlakeParser.parse(content).unwrap();
147        assert_eq!(m.ecosystem, "nix");
148
149        // nixpkgs is filtered
150        assert!(!m.dependencies.iter().any(|d| d.name == "nixpkgs"));
151
152        let names: Vec<&str> = m.dependencies.iter().map(|d| d.name.as_str()).collect();
153        assert!(names.contains(&"flake-utils"));
154        assert!(names.contains(&"rust-overlay"));
155        assert!(names.contains(&"crane"));
156
157        // follows entries should not create separate deps
158        assert_eq!(m.dependencies.len(), 3);
159    }
160}