use serde::Serialize;
use crate::embedded::BLOCKS;
pub const BLOCK_NAMES: [&str; 12] = [
"depend-flake-input.nix.in",
"depend-flake-outputs-arg.nix.in",
"depend-flake-package.nix.in",
"depend-seed-flake.nix.in",
"depend-mise-cargo.toml.in",
"depend-mise-ubi.toml.in",
"depend-mise-pipx.toml.in",
"depend-mise-npm.toml.in",
"depend-seed-mise.toml.in",
"depend-asdf-line.in",
"depend-devbox-flake.json.in",
"depend-seed-devbox.json.in",
];
#[derive(Debug, Clone, Default)]
pub struct Tokens {
pub name: String,
pub input: String,
pub version: String,
pub tag: String,
pub owner_repo: Option<String>,
pub bin: Option<String>,
pub flake_ref: Option<String>,
pub tool_line: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Fragment {
pub id: &'static str,
pub file: String,
pub role: &'static str,
pub placement: &'static str,
pub anchor: Anchor,
pub text: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub present: Option<bool>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Anchor {
pub kind: &'static str,
pub path: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub needle: Option<&'static str>,
}
#[must_use]
pub fn flake_ref(host: Option<&str>, owner_repo: Option<&str>, tag: &str) -> Option<String> {
let owner_repo = owner_repo?;
let scheme = match host? {
"github.com" => "github",
"gitlab.com" => "gitlab",
_ => return None,
};
Some(format!("{scheme}:{owner_repo}/{tag}"))
}
const NIX_KEYWORDS: [&str; 10] = [
"assert", "else", "if", "in", "inherit", "let", "or", "rec", "then", "with",
];
#[must_use]
pub fn nix_input_name(name: &str) -> String {
let mut out = String::new();
for c in name.chars() {
if c.is_ascii_alphanumeric() || matches!(c, '_' | '-') {
out.push(c);
} else if !out.ends_with('-') {
out.push('-');
}
}
let trimmed = out.trim_matches('-');
let mut out = if trimmed.is_empty() {
"dep".to_owned()
} else {
trimmed.to_owned()
};
if !out.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_') {
out = format!("dep-{out}");
}
if NIX_KEYWORDS.contains(&out.as_str()) {
out.push_str("-input");
}
out
}
#[must_use]
pub fn render(text: &str, tokens: &Tokens) -> String {
let mut out = text
.replace("RK_DEP_INPUT", &tokens.input)
.replace("RK_DEP_NAME", &tokens.name)
.replace("RK_DEP_VERSION", &tokens.version)
.replace("RK_DEP_TAG", &tokens.tag);
for (token, value) in [
("RK_DEP_OWNER_REPO", &tokens.owner_repo),
("RK_DEP_BIN", &tokens.bin),
("RK_DEP_FLAKE_REF", &tokens.flake_ref),
("RK_DEP_TOOL_LINE", &tokens.tool_line),
] {
if let Some(value) = value {
out = out.replace(token, value);
}
}
out
}
#[must_use]
pub fn fragment(name: &str, tokens: &Tokens) -> String {
render(block(name), tokens)
.trim_end_matches('\n')
.to_owned()
}
#[must_use]
pub fn seed(name: &str, tokens: &Tokens) -> String {
render(block(name), tokens)
}
#[must_use]
pub fn block(name: &str) -> &'static str {
BLOCKS
.get_file(name)
.and_then(|file| file.contents_utf8())
.unwrap_or_default()
}
#[must_use]
pub fn input_binding<'a>(text: &'a str, input: &str) -> Option<&'a str> {
super::nix::input_declaration(text, input)
}
#[must_use]
pub fn first_found(text: &str, needles: &[&'static str]) -> Option<&'static str> {
needles.iter().copied().find(|needle| text.contains(needle))
}
#[must_use]
pub fn outputs_argument_present(text: &str, input: &str) -> Option<bool> {
let start = text.find("outputs")?;
let rest = &text[start + "outputs".len()..];
let head = &rest[..rest.find(':')?];
if head.contains(input) {
return Some(true);
}
if head.contains("...") || head.contains('@') || !head.contains('{') {
return None;
}
Some(false)
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used)]
use super::{
BLOCK_NAMES, Tokens, block, flake_ref, fragment, nix_input_name, outputs_argument_present,
render, seed,
};
fn full() -> Tokens {
Tokens {
name: "sample-tool".into(),
input: "sample-tool".into(),
version: "1.4.0".into(),
tag: "v1.4.0".into(),
owner_repo: Some("acme/sample-tool".into()),
bin: Some("sam".into()),
flake_ref: Some("github:acme/sample-tool/v1.4.0".into()),
tool_line: Some("\"cargo:sample-tool\" = \"1.4.0\"".into()),
}
}
#[test]
fn every_depend_block_renders_all_its_tokens() {
let tokens = full();
for name in BLOCK_NAMES {
let authored = block(name);
assert!(!authored.is_empty(), "{name}: the block is authored");
assert!(authored.ends_with('\n'), "{name}: one final newline");
let rendered = render(authored, &tokens);
assert!(!rendered.contains("RK_DEP_"), "{name}: every token renders");
}
assert_eq!(
fragment("depend-flake-package.nix.in", &tokens),
"sample-tool.packages.${system}.default"
);
assert_eq!(
fragment("depend-mise-ubi.toml.in", &tokens),
"\"ubi:acme/sample-tool\" = { version = \"1.4.0\", exe = \"sam\" }"
);
assert_eq!(
fragment("depend-devbox-flake.json.in", &tokens),
"\"github:acme/sample-tool/v1.4.0#default\""
);
}
#[test]
fn a_seed_keeps_its_final_newline_and_a_fragment_drops_it() {
let tokens = full();
let flake = seed("depend-seed-flake.nix.in", &tokens);
assert!(flake.ends_with("}\n"));
assert!(flake.contains("${system}"), "the interpolation survives");
assert!(flake.contains("github:acme/sample-tool/v1.4.0"));
let mise = seed("depend-seed-mise.toml.in", &tokens);
assert_eq!(mise, "[tools]\n\"cargo:sample-tool\" = \"1.4.0\"\n");
assert!(!fragment("depend-asdf-line.in", &tokens).ends_with('\n'));
assert_eq!(
fragment("depend-asdf-line.in", &tokens),
"sample-tool 1.4.0"
);
}
#[test]
fn the_flake_ref_carries_no_owner_of_its_own() {
assert_eq!(
flake_ref(Some("github.com"), Some("acme/sample-tool"), "v1.4.0").as_deref(),
Some("github:acme/sample-tool/v1.4.0")
);
assert_eq!(
flake_ref(Some("gitlab.com"), Some("group/sample"), "1.0.0").as_deref(),
Some("gitlab:group/sample/1.0.0")
);
assert_eq!(flake_ref(Some("codeberg.org"), Some("a/b"), "v1"), None);
assert_eq!(flake_ref(Some("github.com"), None, "v1"), None);
let without_ref = Tokens {
flake_ref: None,
..full()
};
assert!(
render(block("depend-flake-input.nix.in"), &without_ref).contains("RK_DEP_FLAKE_REF"),
"an unknown value is left as its token, never invented"
);
}
#[test]
fn a_package_name_becomes_a_nix_identifier() {
assert_eq!(nix_input_name("sample-tool"), "sample-tool");
assert_eq!(nix_input_name("@acme/tool"), "acme-tool");
assert_eq!(nix_input_name("my.tool"), "my-tool");
assert_eq!(nix_input_name("7zip"), "dep-7zip");
assert_eq!(nix_input_name("with"), "with-input");
assert_eq!(nix_input_name("@@"), "dep");
let scoped = Tokens {
name: "@acme/tool".into(),
input: nix_input_name("@acme/tool"),
..full()
};
let rendered = fragment("depend-flake-input.nix.in", &scoped);
assert!(rendered.starts_with("acme-tool = {"), "{rendered}");
assert!(!rendered.contains('@'));
}
#[test]
fn an_input_binding_is_read_to_its_close() {
use super::input_binding;
let text = "inputs = {\n acme-tool = {\n url = \"github:other/thing/v1\";\n };\n nixpkgs.url = \"x\";\n};";
let body = input_binding(text, "acme-tool").expect("a binding");
assert!(body.contains("github:other/thing/v1"));
assert!(!body.contains("nixpkgs"));
assert!(input_binding(text, "nixpkgs").is_some_and(|v| v.contains("\"x\"")));
assert!(
input_binding(
"inputs.acme-tool.url = \"github:other/thing/v1\";",
"acme-tool"
)
.is_some_and(|v| v.contains("github:other/thing/v1")),
"the dotted form is a declaration too"
);
assert_eq!(
input_binding("packages = [ acme-tool ];", "acme-tool"),
None
);
}
#[test]
fn the_outputs_head_is_judged_lexically() {
assert_eq!(
outputs_argument_present(
"outputs = { self, nixpkgs, sample-tool }: {}",
"sample-tool"
),
Some(true)
);
assert_eq!(
outputs_argument_present("outputs =\n { self, nixpkgs }:\n {}", "sample-tool"),
Some(false)
);
assert_eq!(
outputs_argument_present("outputs = { self, ... }: {}", "sample-tool"),
None
);
assert_eq!(
outputs_argument_present("{ inputs = {}; }", "sample-tool"),
None
);
}
}