Skip to main content

release_kit/depend/
nix.rs

1//! Lexical helpers over Nix text, shared by the source and target
2//! observations.
3//!
4//! No Nix parser is embedded: these scanners know comments, strings,
5//! bracket depth, `let … in`, and attribute tokens, which is what a
6//! presence judgement needs and no more. Where they cannot judge, the
7//! callers report a manual pair rather than a guess.
8
9/// Whether a byte can continue a Nix identifier.
10#[must_use]
11pub const fn is_ident(byte: u8) -> bool {
12    byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'\'')
13}
14
15/// Whether `text[index..]` starts the whole word `word`.
16fn word_at(text: &str, index: usize, word: &str) -> bool {
17    let bytes = text.as_bytes();
18    let end = index + word.len();
19    text[index..].starts_with(word)
20        && (index == 0 || !is_ident(bytes[index - 1]))
21        && bytes.get(end).is_none_or(|b| !is_ident(*b))
22}
23
24/// Nix text with block comments, line comments, and string contents
25/// blanked, so a word inside any of them never reads as syntax.
26#[must_use]
27pub fn scrub(text: &str) -> String {
28    let bytes = text.as_bytes();
29    let mut out = String::with_capacity(text.len());
30    let mut i = 0;
31    while i < bytes.len() {
32        if bytes[i..].starts_with(b"/*") {
33            let end = text[i + 2..]
34                .find("*/")
35                .map_or(bytes.len(), |e| i + 2 + e + 2);
36            out.extend(std::iter::repeat_n(' ', end - i));
37            i = end;
38        } else if bytes[i] == b'#' {
39            let end = text[i..].find('\n').map_or(bytes.len(), |e| i + e);
40            out.extend(std::iter::repeat_n(' ', end - i));
41            i = end;
42        } else if bytes[i..].starts_with(b"''") {
43            let end = text[i + 2..]
44                .find("''")
45                .map_or(bytes.len(), |e| i + 2 + e + 2);
46            out.extend(std::iter::repeat_n(' ', end - i));
47            i = end;
48        } else if bytes[i] == b'\x22' {
49            // 0x22 is the double quote, spelled as a byte so the source
50            // scan in `embedded` never reads it as a string that opens here.
51            let mut j = i + 1;
52            while j < bytes.len() && bytes[j] != b'\x22' {
53                j += if bytes[j] == b'\\' { 2 } else { 1 };
54            }
55            let end = (j + 1).min(bytes.len());
56            out.extend(std::iter::repeat_n(' ', end - i));
57            i = end;
58        } else {
59            let c = text[i..].chars().next().unwrap_or(' ');
60            out.push(c);
61            i += c.len_utf8();
62        }
63    }
64    out
65}
66
67/// The text of one binding's value, up to the `;` that closes it.
68///
69/// The closing `;` stands at bracket depth zero, outside every
70/// `let … in`, and past the `;` that each `with <expr>;` or
71/// `assert <expr>;` clause owns at the depth where the clause began.
72#[must_use]
73pub fn binding_value(text: &str) -> &str {
74    let mut depth = 0usize;
75    let mut lets = 0usize;
76    let mut clauses: Vec<(usize, usize)> = Vec::new();
77    let bytes = text.as_bytes();
78    let mut i = 0;
79    while i < bytes.len() {
80        match bytes[i] {
81            b'{' | b'[' | b'(' => depth += 1,
82            b'}' | b']' | b')' => {
83                depth = depth.saturating_sub(1);
84                clauses.retain(|(d, _)| *d <= depth);
85            }
86            b';' => {
87                if clauses.last() == Some(&(depth, lets)) {
88                    clauses.pop();
89                } else if depth == 0 && lets == 0 {
90                    return &text[..i];
91                }
92            }
93            b'l' if word_at(text, i, "let") => lets += 1,
94            b'i' if word_at(text, i, "in") => {
95                lets = lets.saturating_sub(1);
96                clauses.retain(|(_, l)| *l <= lets);
97            }
98            b'w' if word_at(text, i, "with") => clauses.push((depth, lets)),
99            b'a' if word_at(text, i, "assert") => clauses.push((depth, lets)),
100            _ => {}
101        }
102        i += 1;
103    }
104    text
105}
106
107/// Whether `text` binds a default package path at `index`.
108///
109/// The path is `packages.<system>.default`, the system segment an
110/// identifier or an interpolation, or the bare `packages.default`. It
111/// stands as a whole token, not as a segment of a longer path, and an
112/// `=` follows it.
113#[must_use]
114pub fn is_default_package_path(text: &str, index: usize) -> bool {
115    let bound = |tail: &str| tail.trim_start().starts_with('=');
116    if index > 0 && {
117        let previous = text.as_bytes()[index - 1];
118        previous == b'.' || is_ident(previous)
119    } {
120        return false;
121    }
122    let rest = &text[index..];
123    let Some(rest) = rest.strip_prefix("packages.") else {
124        return false;
125    };
126    if let Some(tail) = rest.strip_prefix("default") {
127        return !tail.starts_with(|c: char| is_ident(c as u8)) && bound(tail);
128    }
129    let after_system = rest.strip_prefix("${").map_or_else(
130        || {
131            let bytes = rest.as_bytes();
132            let end = bytes
133                .iter()
134                .position(|b| !is_ident(*b))
135                .unwrap_or(bytes.len());
136            (end > 0).then(|| &rest[end..])
137        },
138        |interpolated| interpolated.find('}').map(|end| &interpolated[end + 1..]),
139    );
140    after_system.is_some_and(|tail| {
141        tail.strip_prefix(".default")
142            .is_some_and(|t| !t.starts_with(|c: char| is_ident(c as u8)) && bound(t))
143    })
144}
145
146/// The text with every `let … in` binding list blanked, so a local
147/// binding never reads as an attribute of the value the expression
148/// returns.
149#[must_use]
150pub fn without_let_bindings(text: &str) -> String {
151    let bytes = text.as_bytes();
152    let mut out = String::with_capacity(text.len());
153    let mut lets = 0usize;
154    let mut i = 0;
155    while i < bytes.len() {
156        if word_at(text, i, "let") {
157            lets += 1;
158        } else if word_at(text, i, "in") && lets > 0 {
159            lets -= 1;
160            out.push_str("  ");
161            i += 2;
162            continue;
163        }
164        let c = text[i..].chars().next().unwrap_or(' ');
165        out.push(if lets > 0 && !c.is_whitespace() {
166            ' '
167        } else {
168            c
169        });
170        i += c.len_utf8();
171    }
172    out
173}
174
175/// The declaration of the flake input `input` inside a flake text.
176///
177/// Both forms are read: `inputs.<input>… = …;`, and `<input> = …;`
178/// inside the body of `inputs = { … };`. Comments and strings are
179/// scrubbed for the search and the raw text is returned, so the URL
180/// survives.
181#[must_use]
182pub fn input_declaration<'a>(raw: &'a str, input: &str) -> Option<&'a str> {
183    let code = scrub(raw);
184    attribute_positions(&code, "inputs")
185        .into_iter()
186        .find_map(|(index, after)| {
187            if let Some(path) = after.strip_prefix('.') {
188                let dotted = path.strip_prefix(input)?;
189                if dotted.starts_with(|c: char| is_ident(c as u8)) && !dotted.starts_with('.') {
190                    return None;
191                }
192                let range = attribute_range(&code[index..], "inputs")?;
193                return Some(&raw[index + range.start..index + range.end]);
194            }
195            let body = attribute_range(&code[index..], "inputs")?;
196            let body_text = &code[index + body.start..index + body.end];
197            let inner = attribute_range(body_text, input)?;
198            Some(&raw[index + body.start + inner.start..index + body.start + inner.end])
199        })
200}
201
202/// Whether `text` binds the attribute `name`: the name as a whole token,
203/// bare or quoted, followed by `=`.
204#[must_use]
205pub fn names_attribute(text: &str, name: &str) -> bool {
206    attribute_positions(text, name)
207        .into_iter()
208        .any(|(_, after)| after.trim_start().starts_with('='))
209}
210
211/// The value of the attribute `name` where `text` binds it, as
212/// `name = <value>;` or through a path `name.<rest> = <value>;`: the text
213/// after the `=` up to the `;` that closes the binding.
214#[must_use]
215pub fn attribute_value<'a>(text: &'a str, name: &str) -> Option<&'a str> {
216    attribute_range(text, name).map(|range| &text[range])
217}
218
219/// The byte range of the attribute `name`'s value in `text`, as
220/// [`attribute_value`] slices it.
221#[must_use]
222pub fn attribute_range(text: &str, name: &str) -> Option<std::ops::Range<usize>> {
223    attribute_positions(text, name)
224        .into_iter()
225        .find_map(|(_, after)| {
226            let after = after.trim_start();
227            let rest = after.strip_prefix('.').map_or(after, |path| {
228                let bytes = path.as_bytes();
229                let end = bytes
230                    .iter()
231                    .position(|b| !(is_ident(*b) || *b == b'.'))
232                    .unwrap_or(bytes.len());
233                path[end..].trim_start()
234            });
235            let value = rest.strip_prefix('=')?;
236            let start = text.len() - value.len();
237            Some(start..start + binding_value(value).len())
238        })
239}
240
241/// Every position where `name` stands as an attribute token, bare or in
242/// double quotes, with the text that follows it.
243fn attribute_positions<'a>(text: &'a str, name: &str) -> Vec<(usize, &'a str)> {
244    let bytes = text.as_bytes();
245    text.match_indices(name)
246        .filter_map(|(index, _)| {
247            let end = index + name.len();
248            let quoted =
249                index > 0 && bytes[index - 1] == b'\x22' && bytes.get(end) == Some(&b'\x22');
250            let bare = (index == 0 || !is_ident(bytes[index - 1]))
251                && bytes.get(end).is_none_or(|b| !is_ident(*b));
252            if quoted {
253                Some((index, &text[end + 1..]))
254            } else if bare {
255                Some((index, &text[end..]))
256            } else {
257                None
258            }
259        })
260        .collect()
261}
262
263#[cfg(test)]
264mod tests {
265    use super::{
266        attribute_value, binding_value, input_declaration, names_attribute, scrub,
267        without_let_bindings,
268    };
269
270    #[test]
271    fn the_scrub_blanks_comments_and_strings_only() {
272        let text = "a = \"x # y\"; # note\n/* block */ b = ''multi\nline''; c = 1;";
273        let out = scrub(text);
274        assert_eq!(out.len(), text.len());
275        assert!(out.contains("a =") && out.contains("b =") && out.contains("c = 1;"));
276        assert!(!out.contains("note") && !out.contains("block") && !out.contains("multi"));
277        assert!(
278            !out.contains("# y"),
279            "a hash inside a string is string content"
280        );
281    }
282
283    #[test]
284    fn a_binding_value_survives_let_and_brackets() {
285        assert_eq!(
286            binding_value("{ a = 1; b = 2; }; rest"),
287            "{ a = 1; b = 2; }"
288        );
289        assert_eq!(
290            binding_value("let x = pkgs.hello; in { default = x; }; devShells = {};"),
291            "let x = pkgs.hello; in { default = x; }"
292        );
293        assert_eq!(
294            binding_value("eachSystem (pkgs: { tool = 1; }); more;"),
295            "eachSystem (pkgs: { tool = 1; })"
296        );
297        assert_eq!(
298            binding_value("with pkgs; { default = hello; }; next;"),
299            "with pkgs; { default = hello; }"
300        );
301        assert_eq!(
302            binding_value("assert x; with pkgs; hello; next;"),
303            "assert x; with pkgs; hello"
304        );
305        assert_eq!(
306            binding_value("{ tool = with pkgs; hello; }; devShells.default = 1;"),
307            "{ tool = with pkgs; hello; }",
308            "a nested clause closes with its bracket"
309        );
310        assert_eq!(
311            binding_value("let x = with pkgs; hello; in { tool = x; }; devShells.default = 1;"),
312            "let x = with pkgs; hello; in { tool = x; }",
313            "a clause inside a let closes with the let"
314        );
315        assert_eq!(binding_value("no terminator"), "no terminator");
316        assert_eq!(binding_value("inherit (x) a; b;"), "inherit (x) a");
317    }
318
319    #[test]
320    fn a_let_binding_is_not_an_attribute_of_the_value() {
321        let text = "eachSystem (system: let default = pkgs.hello; in { tool = default; })";
322        let stripped = without_let_bindings(text);
323        assert_eq!(stripped.len(), text.len());
324        assert!(!names_attribute(&stripped, "default"));
325        assert!(names_attribute(
326            &without_let_bindings("let x = 1; in { default = x; }"),
327            "default"
328        ));
329    }
330
331    #[test]
332    fn an_input_declaration_is_scoped_to_the_inputs() {
333        let braces = "{ inputs = {\n  sample-tool = {\n    url = \"github:other/thing/v1\";\n  };\n }; outputs = _: {}; }";
334        assert!(
335            input_declaration(braces, "sample-tool")
336                .is_some_and(|v| v.contains("github:other/thing/v1"))
337        );
338        let dotted =
339            "{ inputs.sample-tool.url = \"github:other/thing/v1\"; inputs.nixpkgs.url = \"n\"; }";
340        assert!(
341            input_declaration(dotted, "sample-tool")
342                .is_some_and(|v| v.contains("github:other/thing/v1"))
343        );
344        assert!(
345            input_declaration(dotted, "sample").is_none(),
346            "a prefix is not the input"
347        );
348        let output = "{ inputs = { nixpkgs.url = \"n\"; }; outputs = { self, nixpkgs }: { packages.x86_64-linux.sample-tool = nixpkgs.hello; }; }";
349        assert_eq!(
350            input_declaration(output, "sample-tool"),
351            None,
352            "an output is not an input"
353        );
354        let commented = "{ inputs = {\n  # sample-tool = { url = \"github:other/thing/v1\"; };\n  nixpkgs.url = \"n\";\n }; }";
355        assert_eq!(
356            input_declaration(commented, "sample-tool"),
357            None,
358            "a comment is not a declaration"
359        );
360    }
361
362    #[test]
363    fn a_default_package_path_is_system_qualified_or_bare() {
364        use super::is_default_package_path;
365        for text in [
366            "packages.default = x;",
367            "packages.x86_64-linux.default = x;",
368            "packages.${system}.default = x;",
369            "packages.${pkgs.system}.default =\n  x;",
370        ] {
371            assert!(is_default_package_path(text, 0), "{text}");
372        }
373        for text in [
374            "packages.defaultTool = x;",
375            "packages.x86_64-linux.defaults = x;",
376            "packages.x86_64-linux.tool = x;",
377            "packages = { };",
378            "packages.a.b.default = x;",
379            "packages.${system}.default.meta = x;",
380            "packages.${system}.default ]",
381        ] {
382            assert!(!is_default_package_path(text, 0), "{text}");
383        }
384        let reference = "tool-input.packages.${system}.default = x;";
385        assert!(
386            !is_default_package_path(reference, "tool-input.".len()),
387            "a segment of a longer path is a reference"
388        );
389        assert!(
390            !is_default_package_path("mypackages.default = x;", 2),
391            "a longer identifier is not the packages output"
392        );
393    }
394
395    #[test]
396    fn an_attribute_is_a_whole_token() {
397        assert!(names_attribute("{ default = x; }", "default"));
398        assert!(names_attribute("{ \"default\" = x; }", "default"));
399        assert!(!names_attribute("{ notdefault = x; }", "default"));
400        assert!(!names_attribute("{ default-tool = x; }", "default"));
401        assert!(
402            !names_attribute("f default", "default"),
403            "a value is not a binding"
404        );
405    }
406
407    #[test]
408    fn an_attribute_value_is_read_in_every_declaration_form() {
409        let braces = "inputs = {\n  acme-tool = {\n    url = \"github:other/thing/v1\";\n  };\n  nixpkgs.url = \"x\";\n};";
410        let body = attribute_value(braces, "acme-tool").expect("a binding");
411        assert!(body.contains("github:other/thing/v1") && !body.contains("nixpkgs"));
412        let dotted =
413            "inputs.acme-tool.url = \"github:other/thing/v1\";\ninputs.nixpkgs.url = \"n\";";
414        assert!(
415            attribute_value(dotted, "acme-tool")
416                .expect("dotted")
417                .contains("github:other/thing/v1")
418        );
419        let compact = "inputs={acme-tool={url=\"github:other/thing/v1\";};};";
420        assert!(
421            attribute_value(compact, "acme-tool")
422                .expect("compact")
423                .contains("github:other/thing/v1")
424        );
425        let quoted = "inputs = { \"acme-tool\" = { url = \"github:other/thing/v1\"; }; };";
426        assert!(
427            attribute_value(quoted, "acme-tool")
428                .expect("quoted")
429                .contains("github:other/thing/v1")
430        );
431        assert_eq!(
432            attribute_value(braces, "tool"),
433            None,
434            "a suffix is not the name"
435        );
436        assert_eq!(
437            attribute_value("packages = [ acme-tool ];", "acme-tool"),
438            None,
439            "a value is not a binding"
440        );
441    }
442}