use crate::registry::{Merge, PropMeta, Scope};
use crate::source::FileScope;
use crate::value::Const;
use std::fmt::Write;
#[derive(Debug, Copy, Clone, PartialEq)]
pub struct PropSpec {
pub help_heading: Option<&'static str>,
pub writes_to: Option<&'static str>,
pub extensions: &'static [(&'static str, Const)],
}
impl PropSpec {
pub const EMPTY: Self = Self {
help_heading: None,
writes_to: None,
extensions: &[],
};
}
#[derive(Debug, Copy, Clone)]
pub struct SpecSource {
pub kind: &'static str,
pub name: Option<&'static str>,
pub doc_hint: Option<&'static str>,
pub set_hint: Option<&'static str>,
}
#[derive(Debug, Copy, Clone)]
pub struct SpecFile {
pub path: &'static str,
pub findup: bool,
pub scope: FileScope,
pub format: Option<&'static str>,
}
#[derive(Debug, Copy, Clone)]
pub struct ConfigSpec {
pub props: &'static [PropSpec],
pub sources: &'static [SpecSource],
pub files: &'static [SpecFile],
}
impl ConfigSpec {
pub const fn new(
props: &'static [PropSpec],
sources: &'static [SpecSource],
files: &'static [SpecFile],
) -> Self {
Self {
props,
sources,
files,
}
}
}
pub fn spec_kdl(props: &[PropMeta]) -> String {
spec_kdl_with(props, ConfigSpec::new(&[], &[], &[]))
}
pub fn spec_kdl_with(props: &[PropMeta], spec: ConfigSpec) -> String {
assert!(
spec.props.is_empty() || spec.props.len() == props.len(),
"property spec metadata must have one entry per property"
);
let mut out = String::from("config {\n");
for source in spec.sources {
let _ = write!(out, " source {}", quoted(source.kind));
if let Some(name) = source.name {
let _ = write!(out, " name={}", quoted(name));
}
if let Some(hint) = source.doc_hint {
let _ = write!(out, " doc_hint={}", quoted(hint));
}
if let Some(hint) = source.set_hint {
let _ = write!(out, " set_hint={}", quoted(hint));
}
out.push('\n');
}
for file in spec.files {
let _ = write!(out, " file {}", quoted(file.path));
if file.findup {
out.push_str(" findup=#true");
}
match file.scope {
FileScope::Project => {}
FileScope::Global => out.push_str(" scope=\"global\""),
FileScope::System => out.push_str(" scope=\"system\""),
}
if let Some(format) = file.format {
let _ = write!(out, " format={}", quoted(format));
}
out.push('\n');
}
for (index, meta) in props.iter().enumerate() {
let prop_spec = spec.props.get(index).copied().unwrap_or(PropSpec::EMPTY);
let _ = write_prop(&mut out, meta, prop_spec);
}
out.push_str("}\n");
out
}
fn write_prop(out: &mut String, meta: &PropMeta, spec: PropSpec) -> std::fmt::Result {
write!(
out,
" prop {} type={}",
quoted(meta.key),
quoted(&meta.ty.name())
)?;
if let Some(default) = scalar_default(meta.default) {
write!(out, " default={default}")?;
}
if let Some(note) = meta.default_note {
write!(out, " default_note={}", quoted(note))?;
}
if let Some(optional) = meta.optional {
write!(out, " optional=#{optional}")?;
}
match meta.merge {
Merge::Replace => {}
Merge::Union => out.push_str(" merge=\"union\""),
Merge::Deep => out.push_str(" merge=\"deep\""),
}
if let Some(parse) = meta.parse {
write!(out, " parse={}", quoted(parse.name()))?;
}
match meta.scope {
Scope::Any => {}
Scope::Global => out.push_str(" scope=\"global\""),
Scope::Env => out.push_str(" scope=\"env\""),
}
if meta.hide {
out.push_str(" hide=#true");
}
if let Some(deprecated) = meta.deprecated {
write!(out, " deprecated={}", quoted(deprecated))?;
}
if let Some(at) = meta.deprecated_warn_at {
write!(out, " deprecated_warn_at={}", quoted(at))?;
}
if let Some(at) = meta.deprecated_remove_at {
write!(out, " deprecated_remove_at={}", quoted(at))?;
}
if let Some(renamed_to) = meta.renamed_to {
write!(out, " renamed_to={}", quoted(renamed_to))?;
}
if let Some(since) = meta.since {
write!(out, " since={}", quoted(since))?;
}
if let Some(help) = meta.help {
write!(out, " help={}", quoted(help))?;
}
if let Some(long_help) = meta.long_help {
write!(out, " long_help={}", quoted(long_help))?;
}
if let Some(heading) = spec.help_heading {
write!(out, " help_heading={}", quoted(heading))?;
}
if let Some(writes_to) = spec.writes_to {
write!(out, " writes_to={}", quoted(writes_to))?;
}
let mut children = Vec::new();
if let Some(Const::List(items)) = meta.default {
let rendered: Vec<String> = items.iter().map(|item| const_kdl(*item)).collect();
children.push(format!("default {}", rendered.join(" ")));
}
if !meta.envs.is_empty() {
children.push(word_list("env", meta.envs));
}
if !meta.deprecated_envs.is_empty() {
children.push(word_list("deprecated_env", meta.deprecated_envs));
}
if !meta.aliases.is_empty() {
children.push(word_list("alias", meta.aliases));
}
if !meta.cli.is_empty() {
children.push(word_list("cli", meta.cli));
}
for example in meta.examples {
children.push(format!("example {}", quoted(example)));
}
let mut kinds: Vec<&str> = Vec::new();
for (kind, _) in meta.bindings {
if !kinds.contains(kind) {
kinds.push(kind);
}
}
for kind in kinds {
let keys: Vec<String> = meta
.bindings
.iter()
.filter(|(k, _)| *k == kind)
.map(|(_, key)| quoted(key))
.collect();
children.push(format!("source {} {}", quoted(kind), keys.join(" ")));
}
let choices: Vec<String> = meta
.choices
.iter()
.filter(|choice| !matches!(choice, Const::List(_) | Const::Map(_)))
.map(|choice| const_kdl(*choice))
.collect();
if !choices.is_empty() {
let mut block = String::from("choices {\n");
for choice in choices {
let _ = writeln!(block, " choice {choice}");
}
block.push_str(" }");
children.push(block);
}
for (key, value) in spec.extensions {
children.push(format!("x {} {}", quoted(key), const_kdl(*value)));
}
if children.is_empty() {
out.push('\n');
} else {
out.push_str(" {\n");
for child in children {
let _ = writeln!(out, " {child}");
}
out.push_str(" }\n");
}
Ok(())
}
fn scalar_default(default: Option<Const>) -> Option<String> {
match default? {
Const::List(_) | Const::Map(_) => None,
scalar => Some(const_kdl(scalar)),
}
}
fn const_kdl(value: Const) -> String {
match value {
Const::Bool(b) => format!("#{b}"),
Const::Int(i) => i.to_string(),
Const::Float(f) => format!("{f:?}"),
Const::Str(s) => quoted(s),
Const::List(items) => items
.iter()
.map(|item| const_kdl(*item))
.collect::<Vec<_>>()
.join(" "),
Const::Map(_) => String::new(),
}
}
fn word_list(name: &str, words: &[&str]) -> String {
let quoted: Vec<String> = words.iter().map(|word| quoted(word)).collect();
format!("{name} {}", quoted.join(" "))
}
fn quoted(text: &str) -> String {
let mut out = String::with_capacity(text.len() + 2);
out.push('"');
for c in text.chars() {
match c {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if c.is_control() => {
let _ = write!(out, "\\u{{{:x}}}", c as u32);
}
c => out.push(c),
}
}
out.push('"');
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ty::{Parser, Ty};
#[test]
fn a_registry_renders_as_the_config_block_the_spec_grammar_defines() {
static PROPS: &[PropMeta] = &[
PropMeta {
default: Some(Const::Int(4)),
default_note: Some("0 = one per core"),
envs: &["HK_JOBS", "HK_JOB"],
deprecated_envs: &["HK_JOBS_OLD"],
cli: &["--jobs", "-j"],
bindings: &[("git", "hk.jobs")],
help: Some("How many jobs to run at once"),
..PropMeta::new("jobs", Ty::Uint)
},
PropMeta {
merge: Merge::Union,
parse: Some(Parser::ListByComma),
envs: &["HK_EXCLUDE"],
bindings: &[("pkl", "exclude"), ("pkl", "defaults.exclude")],
..PropMeta::new("exclude", Ty::List(&Ty::String))
},
PropMeta {
default: Some(Const::Str("git")),
choices: &[
Const::Str("git"),
Const::Str("patch-file"),
Const::Str("none"),
],
help: Some("How to \"stash\" first"),
..PropMeta::new("stash", Ty::String)
},
PropMeta {
default: Some(Const::List(&[Const::Int(80), Const::Int(443)])),
..PropMeta::new("ports", Ty::List(&Ty::Uint))
},
PropMeta {
scope: Scope::Env,
hide: true,
envs: &["CI"],
..PropMeta::new("ci", Ty::Bool)
},
PropMeta {
optional: Some(true),
aliases: &["fail-fast.legacy", "failfast"],
examples: &["true", "false"],
deprecated: Some("use `stop-on-error`"),
deprecated_warn_at: Some("6.0.0"),
deprecated_remove_at: Some("7.0.0"),
since: Some("5.2.0"),
help: Some("Stop at the first failure"),
long_help: Some("Whether a failing job stops the rest."),
..PropMeta::new("fail_fast", Ty::Option(&Ty::Bool))
},
PropMeta {
choices: &[
Const::Str("plain"),
Const::List(&[Const::Int(1), Const::Int(2)]),
],
..PropMeta::new("level", Ty::Any)
},
];
let kdl = spec_kdl(PROPS);
assert_eq!(
kdl,
r#"config {
prop "jobs" type="uint" default=4 default_note="0 = one per core" help="How many jobs to run at once" {
env "HK_JOBS" "HK_JOB"
deprecated_env "HK_JOBS_OLD"
cli "--jobs" "-j"
source "git" "hk.jobs"
}
prop "exclude" type="list<string>" merge="union" parse="list_by_comma" {
env "HK_EXCLUDE"
source "pkl" "exclude" "defaults.exclude"
}
prop "stash" type="string" default="git" help="How to \"stash\" first" {
choices {
choice "git"
choice "patch-file"
choice "none"
}
}
prop "ports" type="list<uint>" {
default 80 443
}
prop "ci" type="bool" scope="env" hide=#true {
env "CI"
}
prop "fail_fast" type="option<bool>" optional=#true deprecated="use `stop-on-error`" deprecated_warn_at="6.0.0" deprecated_remove_at="7.0.0" since="5.2.0" help="Stop at the first failure" long_help="Whether a failing job stops the rest." {
alias "fail-fast.legacy" "failfast"
example "true"
example "false"
}
prop "level" type="any" {
choices {
choice "plain"
}
}
}
"#
);
}
#[test]
fn a_control_character_in_a_value_is_escaped_rather_than_written() {
static PROPS: &[PropMeta] = &[PropMeta {
help: Some("plain\u{1b}[0m and \u{0}"),
..PropMeta::new("color", Ty::Bool)
}];
assert_eq!(
spec_kdl(PROPS),
"config {\n prop \"color\" type=\"bool\" help=\"plain\\u{1b}[0m and \\u{0}\"\n}\n"
);
}
#[test]
fn spec_only_metadata_is_written_without_changing_property_order() {
static PROPS: &[PropMeta] = &[
PropMeta::new("jobs", Ty::Uint),
PropMeta::new("exclude", Ty::List(&Ty::String)),
];
static PROP_SPECS: &[PropSpec] = &[
PropSpec {
help_heading: Some("Performance"),
writes_to: Some("git"),
extensions: &[("ex.restart_required", Const::Bool(true))],
},
PropSpec::EMPTY,
];
let spec = ConfigSpec::new(
PROP_SPECS,
&[
SpecSource {
kind: "git",
name: Some("git config"),
doc_hint: Some("git config `{key}`"),
set_hint: None,
},
SpecSource {
kind: "npmrc",
name: Some(".npmrc"),
doc_hint: None,
set_hint: None,
},
],
&[
SpecFile {
path: "/etc/ex.toml",
findup: false,
scope: FileScope::System,
format: Some("toml"),
},
SpecFile {
path: "ex.toml",
findup: true,
scope: FileScope::Project,
format: None,
},
],
);
assert_eq!(
spec_kdl_with(PROPS, spec),
r#"config {
source "git" name="git config" doc_hint="git config `{key}`"
source "npmrc" name=".npmrc"
file "/etc/ex.toml" scope="system" format="toml"
file "ex.toml" findup=#true
prop "jobs" type="uint" help_heading="Performance" writes_to="git" {
x "ex.restart_required" #true
}
prop "exclude" type="list<string>"
}
"#
);
}
#[test]
fn choices_no_single_value_can_hold_leave_no_block_behind() {
static PROPS: &[PropMeta] = &[PropMeta {
choices: &[Const::Map(&[("a", Const::Int(1))])],
..PropMeta::new("shape", Ty::Any)
}];
assert_eq!(
spec_kdl(PROPS),
"config {\n prop \"shape\" type=\"any\"\n}\n"
);
}
}