1use crate::registry::{Merge, PropMeta, Scope};
13use crate::source::FileScope;
14use crate::value::Const;
15use std::fmt::Write;
16
17#[derive(Debug, Copy, Clone, PartialEq)]
22pub struct PropSpec {
23 pub help_heading: Option<&'static str>,
24 pub writes_to: Option<&'static str>,
25 pub extensions: &'static [(&'static str, Const)],
27}
28
29impl PropSpec {
30 pub const EMPTY: Self = Self {
31 help_heading: None,
32 writes_to: None,
33 extensions: &[],
34 };
35}
36
37#[derive(Debug, Copy, Clone)]
39pub struct SpecSource {
40 pub kind: &'static str,
41 pub name: Option<&'static str>,
42 pub doc_hint: Option<&'static str>,
43 pub set_hint: Option<&'static str>,
44}
45
46#[derive(Debug, Copy, Clone)]
48pub struct SpecFile {
49 pub path: &'static str,
50 pub findup: bool,
51 pub scope: FileScope,
52 pub format: Option<&'static str>,
53}
54
55#[derive(Debug, Copy, Clone)]
57pub struct ConfigSpec {
58 pub props: &'static [PropSpec],
59 pub sources: &'static [SpecSource],
60 pub files: &'static [SpecFile],
61}
62
63impl ConfigSpec {
64 pub const fn new(
65 props: &'static [PropSpec],
66 sources: &'static [SpecSource],
67 files: &'static [SpecFile],
68 ) -> Self {
69 Self {
70 props,
71 sources,
72 files,
73 }
74 }
75}
76
77pub fn spec_kdl(props: &[PropMeta]) -> String {
82 spec_kdl_with(props, ConfigSpec::new(&[], &[], &[]))
83}
84
85pub fn spec_kdl_with(props: &[PropMeta], spec: ConfigSpec) -> String {
87 assert!(
88 spec.props.is_empty() || spec.props.len() == props.len(),
89 "property spec metadata must have one entry per property"
90 );
91 let mut out = String::from("config {\n");
92 for source in spec.sources {
95 let _ = write!(out, " source {}", quoted(source.kind));
96 if let Some(name) = source.name {
97 let _ = write!(out, " name={}", quoted(name));
98 }
99 if let Some(hint) = source.doc_hint {
100 let _ = write!(out, " doc_hint={}", quoted(hint));
101 }
102 if let Some(hint) = source.set_hint {
103 let _ = write!(out, " set_hint={}", quoted(hint));
104 }
105 out.push('\n');
106 }
107 for file in spec.files {
108 let _ = write!(out, " file {}", quoted(file.path));
109 if file.findup {
110 out.push_str(" findup=#true");
111 }
112 match file.scope {
113 FileScope::Project => {}
114 FileScope::Global => out.push_str(" scope=\"global\""),
115 FileScope::System => out.push_str(" scope=\"system\""),
116 }
117 if let Some(format) = file.format {
118 let _ = write!(out, " format={}", quoted(format));
119 }
120 out.push('\n');
121 }
122 for (index, meta) in props.iter().enumerate() {
123 let prop_spec = spec.props.get(index).copied().unwrap_or(PropSpec::EMPTY);
124 let _ = write_prop(&mut out, meta, prop_spec);
125 }
126 out.push_str("}\n");
127 out
128}
129
130fn write_prop(out: &mut String, meta: &PropMeta, spec: PropSpec) -> std::fmt::Result {
131 write!(
132 out,
133 " prop {} type={}",
134 quoted(meta.key),
135 quoted(&meta.ty.name())
136 )?;
137 if let Some(default) = scalar_default(meta.default) {
138 write!(out, " default={default}")?;
139 }
140 if let Some(note) = meta.default_note {
141 write!(out, " default_note={}", quoted(note))?;
142 }
143 if let Some(optional) = meta.optional {
144 write!(out, " optional=#{optional}")?;
145 }
146 match meta.merge {
147 Merge::Replace => {}
148 Merge::Union => out.push_str(" merge=\"union\""),
149 Merge::Deep => out.push_str(" merge=\"deep\""),
150 }
151 if let Some(parse) = meta.parse {
152 write!(out, " parse={}", quoted(parse.name()))?;
153 }
154 match meta.scope {
155 Scope::Any => {}
156 Scope::Global => out.push_str(" scope=\"global\""),
157 Scope::Env => out.push_str(" scope=\"env\""),
158 }
159 if meta.hide {
160 out.push_str(" hide=#true");
161 }
162 if let Some(deprecated) = meta.deprecated {
163 write!(out, " deprecated={}", quoted(deprecated))?;
164 }
165 if let Some(at) = meta.deprecated_warn_at {
166 write!(out, " deprecated_warn_at={}", quoted(at))?;
167 }
168 if let Some(at) = meta.deprecated_remove_at {
169 write!(out, " deprecated_remove_at={}", quoted(at))?;
170 }
171 if let Some(renamed_to) = meta.renamed_to {
172 write!(out, " renamed_to={}", quoted(renamed_to))?;
173 }
174 if let Some(since) = meta.since {
175 write!(out, " since={}", quoted(since))?;
176 }
177 if let Some(help) = meta.help {
178 write!(out, " help={}", quoted(help))?;
179 }
180 if let Some(long_help) = meta.long_help {
181 write!(out, " long_help={}", quoted(long_help))?;
182 }
183 if let Some(heading) = spec.help_heading {
184 write!(out, " help_heading={}", quoted(heading))?;
185 }
186 if let Some(writes_to) = spec.writes_to {
187 write!(out, " writes_to={}", quoted(writes_to))?;
188 }
189
190 let mut children = Vec::new();
191 if let Some(Const::List(items)) = meta.default {
192 let rendered: Vec<String> = items.iter().map(|item| const_kdl(*item)).collect();
195 children.push(format!("default {}", rendered.join(" ")));
196 }
197 if !meta.envs.is_empty() {
198 children.push(word_list("env", meta.envs));
199 }
200 if !meta.deprecated_envs.is_empty() {
201 children.push(word_list("deprecated_env", meta.deprecated_envs));
202 }
203 if !meta.aliases.is_empty() {
204 children.push(word_list("alias", meta.aliases));
205 }
206 if !meta.cli.is_empty() {
207 children.push(word_list("cli", meta.cli));
208 }
209 for example in meta.examples {
210 children.push(format!("example {}", quoted(example)));
211 }
212 let mut kinds: Vec<&str> = Vec::new();
215 for (kind, _) in meta.bindings {
216 if !kinds.contains(kind) {
217 kinds.push(kind);
218 }
219 }
220 for kind in kinds {
221 let keys: Vec<String> = meta
222 .bindings
223 .iter()
224 .filter(|(k, _)| *k == kind)
225 .map(|(_, key)| quoted(key))
226 .collect();
227 children.push(format!("source {} {}", quoted(kind), keys.join(" ")));
228 }
229 let choices: Vec<String> = meta
233 .choices
234 .iter()
235 .filter(|choice| !matches!(choice, Const::List(_) | Const::Map(_)))
236 .map(|choice| const_kdl(*choice))
237 .collect();
238 if !choices.is_empty() {
239 let mut block = String::from("choices {\n");
240 for choice in choices {
241 let _ = writeln!(block, " choice {choice}");
242 }
243 block.push_str(" }");
244 children.push(block);
245 }
246 for (key, value) in spec.extensions {
247 children.push(format!("x {} {}", quoted(key), const_kdl(*value)));
248 }
249
250 if children.is_empty() {
251 out.push('\n');
252 } else {
253 out.push_str(" {\n");
254 for child in children {
255 let _ = writeln!(out, " {child}");
256 }
257 out.push_str(" }\n");
258 }
259 Ok(())
260}
261
262fn scalar_default(default: Option<Const>) -> Option<String> {
265 match default? {
266 Const::List(_) | Const::Map(_) => None,
267 scalar => Some(const_kdl(scalar)),
268 }
269}
270
271fn const_kdl(value: Const) -> String {
273 match value {
274 Const::Bool(b) => format!("#{b}"),
275 Const::Int(i) => i.to_string(),
276 Const::Float(f) => format!("{f:?}"),
278 Const::Str(s) => quoted(s),
279 Const::List(items) => items
280 .iter()
281 .map(|item| const_kdl(*item))
282 .collect::<Vec<_>>()
283 .join(" "),
284 Const::Map(_) => String::new(),
287 }
288}
289
290fn word_list(name: &str, words: &[&str]) -> String {
291 let quoted: Vec<String> = words.iter().map(|word| quoted(word)).collect();
292 format!("{name} {}", quoted.join(" "))
293}
294
295fn quoted(text: &str) -> String {
297 let mut out = String::with_capacity(text.len() + 2);
298 out.push('"');
299 for c in text.chars() {
300 match c {
301 '\\' => out.push_str("\\\\"),
302 '"' => out.push_str("\\\""),
303 '\n' => out.push_str("\\n"),
304 '\r' => out.push_str("\\r"),
305 '\t' => out.push_str("\\t"),
306 c if c.is_control() => {
310 let _ = write!(out, "\\u{{{:x}}}", c as u32);
311 }
312 c => out.push(c),
313 }
314 }
315 out.push('"');
316 out
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322 use crate::ty::{Parser, Ty};
323
324 #[test]
325 fn a_registry_renders_as_the_config_block_the_spec_grammar_defines() {
326 static PROPS: &[PropMeta] = &[
327 PropMeta {
328 default: Some(Const::Int(4)),
329 default_note: Some("0 = one per core"),
330 envs: &["HK_JOBS", "HK_JOB"],
331 deprecated_envs: &["HK_JOBS_OLD"],
332 cli: &["--jobs", "-j"],
333 bindings: &[("git", "hk.jobs")],
334 help: Some("How many jobs to run at once"),
335 ..PropMeta::new("jobs", Ty::Uint)
336 },
337 PropMeta {
338 merge: Merge::Union,
339 parse: Some(Parser::ListByComma),
340 envs: &["HK_EXCLUDE"],
341 bindings: &[("pkl", "exclude"), ("pkl", "defaults.exclude")],
342 ..PropMeta::new("exclude", Ty::List(&Ty::String))
343 },
344 PropMeta {
345 default: Some(Const::Str("git")),
346 choices: &[
347 Const::Str("git"),
348 Const::Str("patch-file"),
349 Const::Str("none"),
350 ],
351 help: Some("How to \"stash\" first"),
352 ..PropMeta::new("stash", Ty::String)
353 },
354 PropMeta {
355 default: Some(Const::List(&[Const::Int(80), Const::Int(443)])),
356 ..PropMeta::new("ports", Ty::List(&Ty::Uint))
357 },
358 PropMeta {
359 scope: Scope::Env,
360 hide: true,
361 envs: &["CI"],
362 ..PropMeta::new("ci", Ty::Bool)
363 },
364 PropMeta {
367 optional: Some(true),
368 aliases: &["fail-fast.legacy", "failfast"],
369 examples: &["true", "false"],
370 deprecated: Some("use `stop-on-error`"),
371 deprecated_warn_at: Some("6.0.0"),
372 deprecated_remove_at: Some("7.0.0"),
373 since: Some("5.2.0"),
374 help: Some("Stop at the first failure"),
375 long_help: Some("Whether a failing job stops the rest."),
376 ..PropMeta::new("fail_fast", Ty::Option(&Ty::Bool))
377 },
378 PropMeta {
382 choices: &[
383 Const::Str("plain"),
384 Const::List(&[Const::Int(1), Const::Int(2)]),
385 ],
386 ..PropMeta::new("level", Ty::Any)
387 },
388 ];
389 let kdl = spec_kdl(PROPS);
390 assert_eq!(
391 kdl,
392 r#"config {
393 prop "jobs" type="uint" default=4 default_note="0 = one per core" help="How many jobs to run at once" {
394 env "HK_JOBS" "HK_JOB"
395 deprecated_env "HK_JOBS_OLD"
396 cli "--jobs" "-j"
397 source "git" "hk.jobs"
398 }
399 prop "exclude" type="list<string>" merge="union" parse="list_by_comma" {
400 env "HK_EXCLUDE"
401 source "pkl" "exclude" "defaults.exclude"
402 }
403 prop "stash" type="string" default="git" help="How to \"stash\" first" {
404 choices {
405 choice "git"
406 choice "patch-file"
407 choice "none"
408 }
409 }
410 prop "ports" type="list<uint>" {
411 default 80 443
412 }
413 prop "ci" type="bool" scope="env" hide=#true {
414 env "CI"
415 }
416 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." {
417 alias "fail-fast.legacy" "failfast"
418 example "true"
419 example "false"
420 }
421 prop "level" type="any" {
422 choices {
423 choice "plain"
424 }
425 }
426}
427"#
428 );
429 }
430
431 #[test]
435 fn a_control_character_in_a_value_is_escaped_rather_than_written() {
436 static PROPS: &[PropMeta] = &[PropMeta {
437 help: Some("plain\u{1b}[0m and \u{0}"),
438 ..PropMeta::new("color", Ty::Bool)
439 }];
440 assert_eq!(
441 spec_kdl(PROPS),
442 "config {\n prop \"color\" type=\"bool\" help=\"plain\\u{1b}[0m and \\u{0}\"\n}\n"
443 );
444 }
445
446 #[test]
449 fn spec_only_metadata_is_written_without_changing_property_order() {
450 static PROPS: &[PropMeta] = &[
451 PropMeta::new("jobs", Ty::Uint),
452 PropMeta::new("exclude", Ty::List(&Ty::String)),
453 ];
454 static PROP_SPECS: &[PropSpec] = &[
455 PropSpec {
456 help_heading: Some("Performance"),
457 writes_to: Some("git"),
458 extensions: &[("ex.restart_required", Const::Bool(true))],
459 },
460 PropSpec::EMPTY,
461 ];
462 let spec = ConfigSpec::new(
463 PROP_SPECS,
464 &[
465 SpecSource {
466 kind: "git",
467 name: Some("git config"),
468 doc_hint: Some("git config `{key}`"),
469 set_hint: None,
470 },
471 SpecSource {
472 kind: "npmrc",
473 name: Some(".npmrc"),
474 doc_hint: None,
475 set_hint: None,
476 },
477 ],
478 &[
479 SpecFile {
480 path: "/etc/ex.toml",
481 findup: false,
482 scope: FileScope::System,
483 format: Some("toml"),
484 },
485 SpecFile {
486 path: "ex.toml",
487 findup: true,
488 scope: FileScope::Project,
489 format: None,
490 },
491 ],
492 );
493 assert_eq!(
494 spec_kdl_with(PROPS, spec),
495 r#"config {
496 source "git" name="git config" doc_hint="git config `{key}`"
497 source "npmrc" name=".npmrc"
498 file "/etc/ex.toml" scope="system" format="toml"
499 file "ex.toml" findup=#true
500 prop "jobs" type="uint" help_heading="Performance" writes_to="git" {
501 x "ex.restart_required" #true
502 }
503 prop "exclude" type="list<string>"
504}
505"#
506 );
507 }
508
509 #[test]
510 fn choices_no_single_value_can_hold_leave_no_block_behind() {
511 static PROPS: &[PropMeta] = &[PropMeta {
512 choices: &[Const::Map(&[("a", Const::Int(1))])],
513 ..PropMeta::new("shape", Ty::Any)
514 }];
515 assert_eq!(
516 spec_kdl(PROPS),
517 "config {\n prop \"shape\" type=\"any\"\n}\n"
518 );
519 }
520}