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    #![allow(clippy::expect_used)]
266
267    use super::{
268        attribute_value, binding_value, input_declaration, names_attribute, scrub,
269        without_let_bindings,
270    };
271
272    #[test]
273    fn the_scrub_blanks_comments_and_strings_only() {
274        let text = "a = \"x # y\"; # note\n/* block */ b = ''multi\nline''; c = 1;";
275        let out = scrub(text);
276        assert_eq!(out.len(), text.len());
277        assert!(out.contains("a =") && out.contains("b =") && out.contains("c = 1;"));
278        assert!(!out.contains("note") && !out.contains("block") && !out.contains("multi"));
279        assert!(
280            !out.contains("# y"),
281            "a hash inside a string is string content"
282        );
283    }
284
285    #[test]
286    fn a_binding_value_survives_let_and_brackets() {
287        assert_eq!(
288            binding_value("{ a = 1; b = 2; }; rest"),
289            "{ a = 1; b = 2; }"
290        );
291        assert_eq!(
292            binding_value("let x = pkgs.hello; in { default = x; }; devShells = {};"),
293            "let x = pkgs.hello; in { default = x; }"
294        );
295        assert_eq!(
296            binding_value("eachSystem (pkgs: { tool = 1; }); more;"),
297            "eachSystem (pkgs: { tool = 1; })"
298        );
299        assert_eq!(
300            binding_value("with pkgs; { default = hello; }; next;"),
301            "with pkgs; { default = hello; }"
302        );
303        assert_eq!(
304            binding_value("assert x; with pkgs; hello; next;"),
305            "assert x; with pkgs; hello"
306        );
307        assert_eq!(
308            binding_value("{ tool = with pkgs; hello; }; devShells.default = 1;"),
309            "{ tool = with pkgs; hello; }",
310            "a nested clause closes with its bracket"
311        );
312        assert_eq!(
313            binding_value("let x = with pkgs; hello; in { tool = x; }; devShells.default = 1;"),
314            "let x = with pkgs; hello; in { tool = x; }",
315            "a clause inside a let closes with the let"
316        );
317        assert_eq!(binding_value("no terminator"), "no terminator");
318        assert_eq!(binding_value("inherit (x) a; b;"), "inherit (x) a");
319    }
320
321    #[test]
322    fn a_let_binding_is_not_an_attribute_of_the_value() {
323        let text = "eachSystem (system: let default = pkgs.hello; in { tool = default; })";
324        let stripped = without_let_bindings(text);
325        assert_eq!(stripped.len(), text.len());
326        assert!(!names_attribute(&stripped, "default"));
327        assert!(names_attribute(
328            &without_let_bindings("let x = 1; in { default = x; }"),
329            "default"
330        ));
331    }
332
333    #[test]
334    fn an_input_declaration_is_scoped_to_the_inputs() {
335        let braces = "{ inputs = {\n  sample-tool = {\n    url = \"github:other/thing/v1\";\n  };\n }; outputs = _: {}; }";
336        assert!(
337            input_declaration(braces, "sample-tool")
338                .is_some_and(|v| v.contains("github:other/thing/v1"))
339        );
340        let dotted =
341            "{ inputs.sample-tool.url = \"github:other/thing/v1\"; inputs.nixpkgs.url = \"n\"; }";
342        assert!(
343            input_declaration(dotted, "sample-tool")
344                .is_some_and(|v| v.contains("github:other/thing/v1"))
345        );
346        assert!(
347            input_declaration(dotted, "sample").is_none(),
348            "a prefix is not the input"
349        );
350        let output = "{ inputs = { nixpkgs.url = \"n\"; }; outputs = { self, nixpkgs }: { packages.x86_64-linux.sample-tool = nixpkgs.hello; }; }";
351        assert_eq!(
352            input_declaration(output, "sample-tool"),
353            None,
354            "an output is not an input"
355        );
356        let commented = "{ inputs = {\n  # sample-tool = { url = \"github:other/thing/v1\"; };\n  nixpkgs.url = \"n\";\n }; }";
357        assert_eq!(
358            input_declaration(commented, "sample-tool"),
359            None,
360            "a comment is not a declaration"
361        );
362    }
363
364    #[test]
365    fn a_default_package_path_is_system_qualified_or_bare() {
366        use super::is_default_package_path;
367        for text in [
368            "packages.default = x;",
369            "packages.x86_64-linux.default = x;",
370            "packages.${system}.default = x;",
371            "packages.${pkgs.system}.default =\n  x;",
372        ] {
373            assert!(is_default_package_path(text, 0), "{text}");
374        }
375        for text in [
376            "packages.defaultTool = x;",
377            "packages.x86_64-linux.defaults = x;",
378            "packages.x86_64-linux.tool = x;",
379            "packages = { };",
380            "packages.a.b.default = x;",
381            "packages.${system}.default.meta = x;",
382            "packages.${system}.default ]",
383        ] {
384            assert!(!is_default_package_path(text, 0), "{text}");
385        }
386        let reference = "tool-input.packages.${system}.default = x;";
387        assert!(
388            !is_default_package_path(reference, "tool-input.".len()),
389            "a segment of a longer path is a reference"
390        );
391        assert!(
392            !is_default_package_path("mypackages.default = x;", 2),
393            "a longer identifier is not the packages output"
394        );
395    }
396
397    #[test]
398    fn an_attribute_is_a_whole_token() {
399        assert!(names_attribute("{ default = x; }", "default"));
400        assert!(names_attribute("{ \"default\" = x; }", "default"));
401        assert!(!names_attribute("{ notdefault = x; }", "default"));
402        assert!(!names_attribute("{ default-tool = x; }", "default"));
403        assert!(
404            !names_attribute("f default", "default"),
405            "a value is not a binding"
406        );
407    }
408
409    #[test]
410    fn an_attribute_value_is_read_in_every_declaration_form() {
411        let braces = "inputs = {\n  acme-tool = {\n    url = \"github:other/thing/v1\";\n  };\n  nixpkgs.url = \"x\";\n};";
412        let body = attribute_value(braces, "acme-tool").expect("a binding");
413        assert!(body.contains("github:other/thing/v1") && !body.contains("nixpkgs"));
414        let dotted =
415            "inputs.acme-tool.url = \"github:other/thing/v1\";\ninputs.nixpkgs.url = \"n\";";
416        assert!(
417            attribute_value(dotted, "acme-tool")
418                .expect("dotted")
419                .contains("github:other/thing/v1")
420        );
421        let compact = "inputs={acme-tool={url=\"github:other/thing/v1\";};};";
422        assert!(
423            attribute_value(compact, "acme-tool")
424                .expect("compact")
425                .contains("github:other/thing/v1")
426        );
427        let quoted = "inputs = { \"acme-tool\" = { url = \"github:other/thing/v1\"; }; };";
428        assert!(
429            attribute_value(quoted, "acme-tool")
430                .expect("quoted")
431                .contains("github:other/thing/v1")
432        );
433        assert_eq!(
434            attribute_value(braces, "tool"),
435            None,
436            "a suffix is not the name"
437        );
438        assert_eq!(
439            attribute_value("packages = [ acme-tool ];", "acme-tool"),
440            None,
441            "a value is not a binding"
442        );
443    }
444}