1#[must_use]
11pub const fn is_ident(byte: u8) -> bool {
12 byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'\'')
13}
14
15fn 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#[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 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#[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#[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#[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#[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#[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#[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#[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
241fn 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}