1use serde::Serialize;
13
14use crate::embedded::BLOCKS;
15
16pub const BLOCK_NAMES: [&str; 12] = [
18 "depend-flake-input.nix.in",
19 "depend-flake-outputs-arg.nix.in",
20 "depend-flake-package.nix.in",
21 "depend-seed-flake.nix.in",
22 "depend-mise-cargo.toml.in",
23 "depend-mise-ubi.toml.in",
24 "depend-mise-pipx.toml.in",
25 "depend-mise-npm.toml.in",
26 "depend-seed-mise.toml.in",
27 "depend-asdf-line.in",
28 "depend-devbox-flake.json.in",
29 "depend-seed-devbox.json.in",
30];
31
32#[derive(Debug, Clone, Default)]
35pub struct Tokens {
36 pub name: String,
38 pub input: String,
41 pub version: String,
43 pub tag: String,
45 pub owner_repo: Option<String>,
47 pub bin: Option<String>,
49 pub flake_ref: Option<String>,
51 pub tool_line: Option<String>,
53}
54
55#[derive(Debug, Clone, Serialize)]
57pub struct Fragment {
58 pub id: &'static str,
61 pub file: String,
63 pub role: &'static str,
65 pub placement: &'static str,
69 pub anchor: Anchor,
71 pub text: String,
73 #[serde(skip_serializing_if = "Option::is_none")]
76 pub present: Option<bool>,
77}
78
79#[derive(Debug, Clone, Serialize)]
81pub struct Anchor {
82 pub kind: &'static str,
84 pub path: String,
86 #[serde(skip_serializing_if = "Option::is_none")]
89 pub needle: Option<&'static str>,
90}
91
92#[must_use]
95pub fn flake_ref(host: Option<&str>, owner_repo: Option<&str>, tag: &str) -> Option<String> {
96 let owner_repo = owner_repo?;
97 let scheme = match host? {
98 "github.com" => "github",
99 "gitlab.com" => "gitlab",
100 _ => return None,
101 };
102 Some(format!("{scheme}:{owner_repo}/{tag}"))
103}
104
105const NIX_KEYWORDS: [&str; 10] = [
107 "assert", "else", "if", "in", "inherit", "let", "or", "rec", "then", "with",
108];
109
110#[must_use]
117pub fn nix_input_name(name: &str) -> String {
118 let mut out = String::new();
119 for c in name.chars() {
120 if c.is_ascii_alphanumeric() || matches!(c, '_' | '-') {
121 out.push(c);
122 } else if !out.ends_with('-') {
123 out.push('-');
124 }
125 }
126 let trimmed = out.trim_matches('-');
127 let mut out = if trimmed.is_empty() {
128 "dep".to_owned()
129 } else {
130 trimmed.to_owned()
131 };
132 if !out.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_') {
133 out = format!("dep-{out}");
134 }
135 if NIX_KEYWORDS.contains(&out.as_str()) {
136 out.push_str("-input");
137 }
138 out
139}
140
141#[must_use]
143pub fn render(text: &str, tokens: &Tokens) -> String {
144 let mut out = text
145 .replace("RK_DEP_INPUT", &tokens.input)
146 .replace("RK_DEP_NAME", &tokens.name)
147 .replace("RK_DEP_VERSION", &tokens.version)
148 .replace("RK_DEP_TAG", &tokens.tag);
149 for (token, value) in [
150 ("RK_DEP_OWNER_REPO", &tokens.owner_repo),
151 ("RK_DEP_BIN", &tokens.bin),
152 ("RK_DEP_FLAKE_REF", &tokens.flake_ref),
153 ("RK_DEP_TOOL_LINE", &tokens.tool_line),
154 ] {
155 if let Some(value) = value {
156 out = out.replace(token, value);
157 }
158 }
159 out
160}
161
162#[must_use]
164pub fn fragment(name: &str, tokens: &Tokens) -> String {
165 render(block(name), tokens)
166 .trim_end_matches('\n')
167 .to_owned()
168}
169
170#[must_use]
172pub fn seed(name: &str, tokens: &Tokens) -> String {
173 render(block(name), tokens)
174}
175
176#[must_use]
179pub fn block(name: &str) -> &'static str {
180 BLOCKS
181 .get_file(name)
182 .and_then(|file| file.contents_utf8())
183 .unwrap_or_default()
184}
185
186#[must_use]
190pub fn input_binding<'a>(text: &'a str, input: &str) -> Option<&'a str> {
191 super::nix::input_declaration(text, input)
192}
193
194#[must_use]
196pub fn first_found(text: &str, needles: &[&'static str]) -> Option<&'static str> {
197 needles.iter().copied().find(|needle| text.contains(needle))
198}
199
200#[must_use]
207pub fn outputs_argument_present(text: &str, input: &str) -> Option<bool> {
208 let start = text.find("outputs")?;
209 let rest = &text[start + "outputs".len()..];
210 let head = &rest[..rest.find(':')?];
211 if head.contains(input) {
212 return Some(true);
213 }
214 if head.contains("...") || head.contains('@') || !head.contains('{') {
215 return None;
216 }
217 Some(false)
218}
219
220#[cfg(test)]
221mod tests {
222 #![allow(clippy::expect_used)]
223
224 use super::{
225 BLOCK_NAMES, Tokens, block, flake_ref, fragment, nix_input_name, outputs_argument_present,
226 render, seed,
227 };
228
229 fn full() -> Tokens {
230 Tokens {
231 name: "sample-tool".into(),
232 input: "sample-tool".into(),
233 version: "1.4.0".into(),
234 tag: "v1.4.0".into(),
235 owner_repo: Some("acme/sample-tool".into()),
236 bin: Some("sam".into()),
237 flake_ref: Some("github:acme/sample-tool/v1.4.0".into()),
238 tool_line: Some("\"cargo:sample-tool\" = \"1.4.0\"".into()),
239 }
240 }
241
242 #[test]
244 fn every_depend_block_renders_all_its_tokens() {
245 let tokens = full();
246 for name in BLOCK_NAMES {
247 let authored = block(name);
248 assert!(!authored.is_empty(), "{name}: the block is authored");
249 assert!(authored.ends_with('\n'), "{name}: one final newline");
250 let rendered = render(authored, &tokens);
251 assert!(!rendered.contains("RK_DEP_"), "{name}: every token renders");
252 }
253 assert_eq!(
254 fragment("depend-flake-package.nix.in", &tokens),
255 "sample-tool.packages.${system}.default"
256 );
257 assert_eq!(
258 fragment("depend-mise-ubi.toml.in", &tokens),
259 "\"ubi:acme/sample-tool\" = { version = \"1.4.0\", exe = \"sam\" }"
260 );
261 assert_eq!(
262 fragment("depend-devbox-flake.json.in", &tokens),
263 "\"github:acme/sample-tool/v1.4.0#default\""
264 );
265 }
266
267 #[test]
268 fn a_seed_keeps_its_final_newline_and_a_fragment_drops_it() {
269 let tokens = full();
270 let flake = seed("depend-seed-flake.nix.in", &tokens);
271 assert!(flake.ends_with("}\n"));
272 assert!(flake.contains("${system}"), "the interpolation survives");
273 assert!(flake.contains("github:acme/sample-tool/v1.4.0"));
274 let mise = seed("depend-seed-mise.toml.in", &tokens);
275 assert_eq!(mise, "[tools]\n\"cargo:sample-tool\" = \"1.4.0\"\n");
276 assert!(!fragment("depend-asdf-line.in", &tokens).ends_with('\n'));
277 assert_eq!(
278 fragment("depend-asdf-line.in", &tokens),
279 "sample-tool 1.4.0"
280 );
281 }
282
283 #[test]
284 fn the_flake_ref_carries_no_owner_of_its_own() {
285 assert_eq!(
286 flake_ref(Some("github.com"), Some("acme/sample-tool"), "v1.4.0").as_deref(),
287 Some("github:acme/sample-tool/v1.4.0")
288 );
289 assert_eq!(
290 flake_ref(Some("gitlab.com"), Some("group/sample"), "1.0.0").as_deref(),
291 Some("gitlab:group/sample/1.0.0")
292 );
293 assert_eq!(flake_ref(Some("codeberg.org"), Some("a/b"), "v1"), None);
294 assert_eq!(flake_ref(Some("github.com"), None, "v1"), None);
295 let without_ref = Tokens {
296 flake_ref: None,
297 ..full()
298 };
299 assert!(
300 render(block("depend-flake-input.nix.in"), &without_ref).contains("RK_DEP_FLAKE_REF"),
301 "an unknown value is left as its token, never invented"
302 );
303 }
304
305 #[test]
306 fn a_package_name_becomes_a_nix_identifier() {
307 assert_eq!(nix_input_name("sample-tool"), "sample-tool");
308 assert_eq!(nix_input_name("@acme/tool"), "acme-tool");
309 assert_eq!(nix_input_name("my.tool"), "my-tool");
310 assert_eq!(nix_input_name("7zip"), "dep-7zip");
311 assert_eq!(nix_input_name("with"), "with-input");
312 assert_eq!(nix_input_name("@@"), "dep");
313 let scoped = Tokens {
314 name: "@acme/tool".into(),
315 input: nix_input_name("@acme/tool"),
316 ..full()
317 };
318 let rendered = fragment("depend-flake-input.nix.in", &scoped);
319 assert!(rendered.starts_with("acme-tool = {"), "{rendered}");
320 assert!(!rendered.contains('@'));
321 }
322
323 #[test]
324 fn an_input_binding_is_read_to_its_close() {
325 use super::input_binding;
326 let text = "inputs = {\n acme-tool = {\n url = \"github:other/thing/v1\";\n };\n nixpkgs.url = \"x\";\n};";
327 let body = input_binding(text, "acme-tool").expect("a binding");
328 assert!(body.contains("github:other/thing/v1"));
329 assert!(!body.contains("nixpkgs"));
330 assert!(input_binding(text, "nixpkgs").is_some_and(|v| v.contains("\"x\"")));
331 assert!(
332 input_binding(
333 "inputs.acme-tool.url = \"github:other/thing/v1\";",
334 "acme-tool"
335 )
336 .is_some_and(|v| v.contains("github:other/thing/v1")),
337 "the dotted form is a declaration too"
338 );
339 assert_eq!(
340 input_binding("packages = [ acme-tool ];", "acme-tool"),
341 None
342 );
343 }
344
345 #[test]
346 fn the_outputs_head_is_judged_lexically() {
347 assert_eq!(
348 outputs_argument_present(
349 "outputs = { self, nixpkgs, sample-tool }: {}",
350 "sample-tool"
351 ),
352 Some(true)
353 );
354 assert_eq!(
355 outputs_argument_present("outputs =\n { self, nixpkgs }:\n {}", "sample-tool"),
356 Some(false)
357 );
358 assert_eq!(
359 outputs_argument_present("outputs = { self, ... }: {}", "sample-tool"),
360 None
361 );
362 assert_eq!(
363 outputs_argument_present("{ inputs = {}; }", "sample-tool"),
364 None
365 );
366 }
367}