1use std::collections::HashMap;
33use std::path::{Path, PathBuf};
34
35use indexmap::IndexMap;
36use miette::SourceSpan;
37use serde::Serialize;
38
39use crate::error::UsageErr;
40use crate::spec::context::ParsingContext;
41use crate::spec::helpers::NodeHelper;
42use crate::spec::spec_flag_forms_overlap;
43use crate::{SpecCommand, SpecFlag};
44
45#[derive(Debug, Clone, Serialize)]
50#[non_exhaustive]
51pub struct SpecFlagSet {
52 pub name: String,
53 pub flags: Vec<SpecFlag>,
55 #[serde(skip_serializing_if = "Vec::is_empty")]
61 pub uses: Vec<SpecUse>,
62 #[serde(skip)]
64 pub(crate) span: SourceSpan,
65 #[serde(skip)]
71 pub(crate) declared_in: PathBuf,
72}
73
74#[derive(Debug, Clone, Serialize)]
76#[non_exhaustive]
77pub struct SpecUse {
78 pub names: Vec<String>,
80 pub at: usize,
85 #[serde(skip)]
88 pub(crate) span: SourceSpan,
89}
90
91impl Default for SpecFlagSet {
92 fn default() -> Self {
93 Self {
94 name: String::new(),
95 flags: vec![],
96 uses: vec![],
97 span: (0, 0).into(),
100 declared_in: PathBuf::new(),
101 }
102 }
103}
104
105pub(crate) fn declaring_file(file: &Path) -> PathBuf {
111 std::fs::canonicalize(file).unwrap_or_else(|_| file.to_path_buf())
114}
115
116impl SpecFlagSet {
117 pub(crate) fn parse(ctx: &ParsingContext, node: &NodeHelper) -> Result<Self, UsageErr> {
118 node.ensure_arg_len(1..=1)?;
119 let mut set = Self {
120 name: node.arg(0)?.ensure_string()?,
121 flags: vec![],
122 uses: vec![],
123 span: node.span(),
124 declared_in: declaring_file(&ctx.file),
125 };
126 if let Some((k, v)) = node.props().first() {
127 bail_parse!(ctx, v.entry.span(), "unsupported flagset prop {k}");
128 }
129 for child in node.children() {
130 match child.name() {
131 "flag" => set.flags.push(SpecFlag::parse(ctx, &child)?),
132 "use" => set.uses.push(SpecUse::parse(ctx, &child, set.flags.len())?),
133 "arg" => bail_parse!(
140 ctx,
141 child.node.name().span(),
142 "a flagset holds flags, not arguments: declare the argument on \
143 each command that takes it"
144 ),
145 k => bail_parse!(ctx, child.node.name().span(), "unsupported flagset key {k}"),
146 }
147 }
148 Ok(set)
149 }
150}
151
152impl SpecUse {
153 pub(crate) fn parse(
154 ctx: &ParsingContext,
155 node: &NodeHelper,
156 at: usize,
157 ) -> Result<Self, UsageErr> {
158 node.ensure_arg_len(1..)?;
159 if let Some((k, v)) = node.props().first() {
160 bail_parse!(ctx, v.entry.span(), "unsupported use prop {k}");
161 }
162 if !node.children().is_empty() {
163 bail_parse!(
164 ctx,
165 node.span(),
166 "`use` names flagsets and holds nothing: declare the flags in the \
167 flagset itself"
168 );
169 }
170 Ok(Self {
171 names: node
172 .args()
173 .map(|a| a.ensure_string())
174 .collect::<Result<Vec<_>, _>>()?,
175 at,
176 span: node.span(),
177 })
178 }
179}
180
181pub(crate) fn expand(
193 ctx: &ParsingContext,
194 cmd: &mut SpecCommand,
195 flagsets: &mut IndexMap<String, SpecFlagSet>,
196) -> Result<(), UsageErr> {
197 let mut cache = {
198 let mut resolver = Resolver {
199 ctx,
200 flagsets,
201 cache: HashMap::new(),
202 stack: vec![],
203 };
204 for (name, set) in resolver.flagsets {
205 resolver.resolve(name, set.span)?;
206 }
207 resolver.cache
208 };
209 for (name, set) in flagsets.iter_mut() {
212 if let Some(flags) = cache.get(name) {
213 set.flags = flags.clone();
214 }
215 set.uses.clear();
216 }
217 let mut resolver = Resolver {
218 ctx,
219 flagsets,
220 cache: core::mem::take(&mut cache),
221 stack: vec![],
222 };
223 expand_cmd(cmd, &mut resolver)
224}
225
226fn expand_cmd(cmd: &mut SpecCommand, resolver: &mut Resolver) -> Result<(), UsageErr> {
227 let uses = std::mem::take(&mut cmd.uses);
230 splice(&mut cmd.flags, &uses, resolver)?;
231 for sub in cmd.subcommands.values_mut() {
232 expand_cmd(sub, resolver)?;
233 }
234 Ok(())
235}
236
237fn splice(
239 flags: &mut Vec<SpecFlag>,
240 uses: &[SpecUse],
241 resolver: &mut Resolver,
242) -> Result<(), UsageErr> {
243 let mut inserted = 0;
244 for u in uses {
245 let mut at = (u.at + inserted).min(flags.len());
246 for name in &u.names {
247 for flag in resolver.resolve(name, u.span)? {
248 if flags.iter().any(|f| spec_flag_forms_overlap(f, &flag)) {
253 continue;
254 }
255 flags.insert(at, flag);
256 at += 1;
257 inserted += 1;
258 }
259 }
260 }
261 Ok(())
262}
263
264struct Resolver<'a> {
265 ctx: &'a ParsingContext,
266 flagsets: &'a IndexMap<String, SpecFlagSet>,
267 cache: HashMap<String, Vec<SpecFlag>>,
269 stack: Vec<String>,
272}
273
274impl Resolver<'_> {
275 fn resolve(&mut self, name: &str, span: SourceSpan) -> Result<Vec<SpecFlag>, UsageErr> {
276 if let Some(flags) = self.cache.get(name) {
277 return Ok(flags.clone());
278 }
279 if self.stack.iter().any(|seen| seen == name) {
280 let ctx = self.ctx;
281 let path = self
282 .stack
283 .iter()
284 .map(String::as_str)
285 .chain([name])
286 .collect::<Vec<_>>()
287 .join(" -> ");
288 bail_parse!(ctx, span, "flagset cycle: {path}");
289 }
290 let flagsets = self.flagsets;
291 let Some(set) = flagsets.get(name) else {
292 let ctx = self.ctx;
293 let known = flagsets.keys().cloned().collect::<Vec<_>>().join(", ");
294 let hint = match known.is_empty() {
295 true => "no flagsets are declared".to_string(),
296 false => format!("declared: {known}"),
297 };
298 bail_parse!(ctx, span, "unknown flagset \"{name}\" ({hint})");
299 };
300 self.stack.push(name.to_string());
301 let mut flags = set.flags.clone();
302 let result = splice(&mut flags, &set.uses, self);
303 self.stack.pop();
304 result?;
305 self.cache.insert(name.to_string(), flags.clone());
306 Ok(flags)
307 }
308}
309
310#[cfg(test)]
311mod tests {
312 use crate::Spec;
313 use insta::assert_snapshot;
314
315 fn parse(input: &str) -> Spec {
316 Spec::parse(&Default::default(), input).unwrap()
317 }
318
319 fn err(input: &str) -> String {
322 match Spec::parse(&Default::default(), input).unwrap_err() {
323 crate::error::UsageErr::InvalidInput(msg, _, _) => msg,
324 err => panic!("unexpected error: {err:?}"),
325 }
326 }
327
328 #[test]
329 fn a_set_expands_where_the_use_stands() {
330 let spec = parse(
333 r#"
334bin "ex"
335flagset "output" {
336 flag "-v --verbose" help="Print more"
337 flag "--json" help="JSON output"
338}
339cmd "build" {
340 flag "--release"
341 use "output"
342 flag "--target" {
343 arg "<triple>"
344 }
345}
346 "#,
347 );
348 assert_snapshot!(spec, @r#"
349 name ex
350 bin ex
351 cmd build {
352 flag --release
353 flag "-v --verbose" help="Print more"
354 flag --json help="JSON output"
355 flag --target {
356 arg <triple>
357 }
358 }
359 "#);
360 }
361
362 #[test]
363 fn one_use_names_several_sets_and_the_root_can_use_them_too() {
364 let spec = parse(
365 r#"
366bin "ex"
367use "logging" "output"
368flagset "logging" {
369 flag "-v --verbose" global=#true
370}
371flagset "output" {
372 flag "--json"
373}
374 "#,
375 );
376 assert_snapshot!(spec, @r#"
379 name ex
380 bin ex
381 flag "-v --verbose" global=#true
382 flag --json
383 "#);
384 }
385
386 #[test]
387 fn a_set_composes_other_sets_and_a_diamond_contributes_once() {
388 let spec = parse(
389 r#"
390bin "ex"
391flagset "common" {
392 flag "-v --verbose"
393}
394flagset "output" {
395 use "common"
396 flag "--json"
397}
398flagset "input" {
399 use "common"
400 flag "--stdin"
401}
402cmd "run" {
403 use "output" "input"
404}
405 "#,
406 );
407 assert_snapshot!(spec, @r#"
408 name ex
409 bin ex
410 cmd run {
411 flag "-v --verbose"
412 flag --json
413 flag --stdin
414 }
415 "#);
416 }
417
418 #[test]
419 fn the_commands_own_declaration_wins() {
420 let spec = parse(
423 r#"
424bin "ex"
425flagset "output" {
426 flag "--json" help="JSON output"
427 flag "-q --quiet"
428}
429cmd "build" {
430 use "output"
431 flag "--json" help="build's own JSON, with a schema"
432}
433 "#,
434 );
435 assert_snapshot!(spec, @r#"
436 name ex
437 bin ex
438 cmd build {
439 flag "-q --quiet"
440 flag --json help="build's own JSON, with a schema"
441 }
442 "#);
443 }
444
445 #[test]
446 fn a_short_form_collision_counts_as_the_same_flag() {
447 let spec = parse(
450 r#"
451bin "ex"
452flagset "common" {
453 flag "-j --jobs" {
454 arg "<n>"
455 }
456}
457cmd "build" {
458 use "common"
459 flag "-j --job-count" {
460 arg "<n>"
461 }
462}
463 "#,
464 );
465 assert_snapshot!(spec, @r#"
466 name ex
467 bin ex
468 cmd build {
469 flag "-j --job-count" {
470 arg <n>
471 }
472 }
473 "#);
474 }
475
476 #[test]
477 fn a_set_reaches_every_depth_of_the_tree() {
478 let spec = parse(
479 r#"
480bin "ex"
481flagset "common" {
482 flag "-v --verbose"
483}
484cmd "remote" {
485 use "common"
486 cmd "add" {
487 use "common"
488 arg "<name>"
489 }
490}
491 "#,
492 );
493 assert_snapshot!(spec, @r#"
494 name ex
495 bin ex
496 cmd remote {
497 flag "-v --verbose"
498 cmd add {
499 flag "-v --verbose"
500 arg <name>
501 }
502 }
503 "#);
504 }
505
506 #[test]
507 fn an_included_file_can_hold_the_shared_sets() {
508 let dir = tempfile::tempdir().unwrap();
511 let common = dir.path().join("common.usage.kdl");
512 let root = dir.path().join("ex.usage.kdl");
513 std::fs::write(
514 &common,
515 "flagset \"common\" {\n flag \"-v --verbose\"\n}\n",
516 )
517 .unwrap();
518 std::fs::write(
519 &root,
520 "bin \"ex\"\ninclude file=\"./common.usage.kdl\"\ncmd \"build\" {\n use \"common\"\n}\n",
521 )
522 .unwrap();
523
524 let spec = Spec::parse_file(&root).unwrap();
525
526 assert_snapshot!(spec, @r#"
527 name ex
528 bin ex
529 cmd build {
530 flag "-v --verbose"
531 }
532 "#);
533 }
534
535 #[test]
536 fn a_set_is_resolved_by_the_file_that_wrote_it() {
537 let dir = tempfile::tempdir().unwrap();
541 let common = dir.path().join("common.usage.kdl");
542 let root = dir.path().join("ex.usage.kdl");
543 std::fs::write(&common, "flagset \"child\" {\n use \"parent-only\"\n}\n").unwrap();
544 std::fs::write(
545 &root,
546 "bin \"ex\"\ninclude file=\"./common.usage.kdl\"\nflagset \"parent-only\" {\n flag \"--from-parent\"\n}\ncmd \"build\" {\n use \"child\"\n}\n",
547 )
548 .unwrap();
549
550 let err = Spec::parse_file(&root).unwrap_err();
551 let crate::error::UsageErr::InvalidInput(msg, _, source) = err else {
552 panic!("unexpected error: {err:?}");
553 };
554 assert!(
555 msg.contains("unknown flagset \"parent-only\" (declared: child)"),
556 "{msg}"
557 );
558 assert!(
561 source.name().ends_with("common.usage.kdl"),
562 "{:?}",
563 source.name()
564 );
565 }
566
567 #[test]
568 fn a_use_goes_with_the_flags_an_include_replaced() {
569 let dir = tempfile::tempdir().unwrap();
574 let included = dir.path().join("overrides.usage.kdl");
575 let root = dir.path().join("ex.usage.kdl");
576 std::fs::write(&included, "flag \"--from-include\"\n").unwrap();
577 std::fs::write(
578 &root,
579 "bin \"ex\"\nflagset \"common\" {\n flag \"-v --verbose\"\n}\nflag \"--own\"\n use \"common\"\ninclude file=\"./overrides.usage.kdl\"\n",
580 )
581 .unwrap();
582
583 let spec = Spec::parse_file(&root).unwrap();
584
585 assert_snapshot!(spec, @r#"
586 name ex
587 bin ex
588 flag --from-include
589 "#);
590 }
591
592 #[test]
593 fn a_use_survives_an_include_that_declares_no_flags() {
594 let dir = tempfile::tempdir().unwrap();
597 let included = dir.path().join("common.usage.kdl");
598 let root = dir.path().join("ex.usage.kdl");
599 std::fs::write(
600 &included,
601 "flagset \"common\" {\n flag \"-v --verbose\"\n}\n",
602 )
603 .unwrap();
604 std::fs::write(
605 &root,
606 "bin \"ex\"\nuse \"common\"\ninclude file=\"./common.usage.kdl\"\n",
607 )
608 .unwrap();
609
610 let spec = Spec::parse_file(&root).unwrap();
611
612 assert_snapshot!(spec, @r#"
613 name ex
614 bin ex
615 flag "-v --verbose"
616 "#);
617 }
618
619 #[test]
620 fn a_set_nothing_uses_is_still_resolved() {
621 let msg = err("flagset \"a\" {\n use \"missing\"\n}\n");
624 assert!(msg.contains("unknown flagset \"missing\""), "{msg}");
625 }
626
627 #[test]
628 fn an_included_set_may_compose_one_from_its_own_file() {
629 let dir = tempfile::tempdir().unwrap();
630 let common = dir.path().join("common.usage.kdl");
631 let root = dir.path().join("ex.usage.kdl");
632 std::fs::write(
633 &common,
634 "flagset \"logging\" {\n flag \"-v --verbose\"\n}\nflagset \"common\" {\n use \"logging\"\n flag \"--config\"\n}\n",
635 )
636 .unwrap();
637 std::fs::write(
638 &root,
639 "bin \"ex\"\ninclude file=\"./common.usage.kdl\"\ncmd \"build\" {\n use \"common\"\n}\n",
640 )
641 .unwrap();
642
643 let spec = Spec::parse_file(&root).unwrap();
644
645 assert_snapshot!(spec, @r#"
646 name ex
647 bin ex
648 cmd build {
649 flag "-v --verbose"
650 flag --config
651 }
652 "#);
653 }
654
655 #[test]
656 fn the_flags_a_set_brought_parse_like_any_other() {
657 let spec = parse(
660 r#"
661bin "ex"
662flagset "common" {
663 flag "-j --jobs" {
664 arg "<n>"
665 }
666}
667cmd "build" {
668 use "common"
669}
670 "#,
671 );
672 let words = ["ex", "build", "--jobs", "4"].map(String::from);
673 let parsed = crate::parse(&spec, &words).unwrap();
674 assert_eq!(parsed.as_env().get("usage_jobs").unwrap(), "4");
675 }
676
677 #[test]
678 fn a_global_from_a_set_is_inherited_like_any_other() {
679 let spec = parse(
683 r#"
684bin "ex"
685flagset "logging" {
686 flag "-v --verbose" global=#true
687}
688use "logging"
689cmd "build"
690 "#,
691 );
692 let words = ["ex", "build", "--verbose"].map(String::from);
693 let parsed = crate::parse(&spec, &words).unwrap();
694 assert_eq!(parsed.as_env().get("usage_verbose").unwrap(), "true");
695 }
696
697 #[test]
698 fn an_unknown_set_says_what_is_declared() {
699 let msg = err(r#"
700bin "ex"
701flagset "output" {
702 flag "--json"
703}
704cmd "build" {
705 use "outupt"
706}
707 "#);
708 assert!(
709 msg.contains("unknown flagset \"outupt\" (declared: output)"),
710 "{msg}"
711 );
712 }
713
714 #[test]
715 fn a_use_with_no_sets_at_all_says_so() {
716 let msg = err("bin \"ex\"\ncmd \"build\" {\n use \"output\"\n}\n");
717 assert!(
718 msg.contains("unknown flagset \"output\" (no flagsets are declared)"),
719 "{msg}"
720 );
721 }
722
723 #[test]
724 fn a_cycle_is_reported_as_the_path_that_closes_it() {
725 let msg = err(r#"
726bin "ex"
727flagset "a" {
728 use "b"
729}
730flagset "b" {
731 use "a"
732}
733cmd "build" {
734 use "a"
735}
736 "#);
737 assert!(msg.contains("flagset cycle: a -> b -> a"), "{msg}");
738 }
739
740 #[test]
741 fn a_set_that_uses_itself_is_the_same_error() {
742 let msg = err("bin \"ex\"\nflagset \"a\" {\n use \"a\"\n}\nuse \"a\"\n");
743 assert!(msg.contains("flagset cycle: a -> a"), "{msg}");
744 }
745
746 #[test]
747 fn a_name_may_be_declared_once() {
748 let msg = err(r#"
749flagset "output" {
750 flag "--json"
751}
752flagset "output" {
753 flag "--yaml"
754}
755 "#);
756 assert!(msg.contains("a flagset may be declared only once"), "{msg}");
757 }
758
759 fn err_file(root: &std::path::Path) -> String {
761 match Spec::parse_file(root).unwrap_err() {
762 crate::error::UsageErr::InvalidInput(msg, _, _) => msg,
763 err => panic!("unexpected error: {err:?}"),
764 }
765 }
766
767 #[test]
768 fn a_name_an_include_also_declares_is_refused_whichever_side_wrote_it_first() {
769 let dir = tempfile::tempdir().unwrap();
775 let common = dir.path().join("common.usage.kdl");
776 std::fs::write(&common, "flagset \"output\" {\n flag \"--yaml\"\n}\n").unwrap();
777 let own = "flagset \"output\" {\n flag \"--json\"\n}\n";
778 let include = "include file=\"./common.usage.kdl\"\n";
779
780 let after = dir.path().join("after.usage.kdl");
781 std::fs::write(&after, format!("bin \"ex\"\n{own}{include}")).unwrap();
782 let msg = err_file(&after);
783 assert!(
784 msg.contains("a flagset may be declared only once")
785 && msg.contains("common.usage.kdl")
786 && msg.contains("\"output\""),
787 "{msg}"
788 );
789
790 let before = dir.path().join("before.usage.kdl");
792 std::fs::write(&before, format!("bin \"ex\"\n{include}{own}")).unwrap();
793 let msg = err_file(&before);
794 assert!(msg.contains("a flagset may be declared only once"), "{msg}");
795 }
796
797 #[test]
798 fn a_shared_file_may_reach_a_spec_by_two_routes() {
799 let dir = tempfile::tempdir().unwrap();
805 std::fs::create_dir(dir.path().join("cmds")).unwrap();
806 std::fs::write(
807 dir.path().join("common.usage.kdl"),
808 "flagset \"common\" {\n flag \"-v --verbose\"\n}\n",
809 )
810 .unwrap();
811 for cmd in ["build", "test"] {
812 let body = format!(
815 "include file=\"../common.usage.kdl\"\ncmd \"{cmd}\" {{\n use \"common\"\n}}\n"
816 );
817 std::fs::write(
818 dir.path().join("cmds").join(format!("{cmd}.usage.kdl")),
819 body,
820 )
821 .unwrap();
822 }
823 let root = dir.path().join("ex.usage.kdl");
824 std::fs::write(
825 &root,
826 "bin \"ex\"\ninclude file=\"./common.usage.kdl\"\ninclude file=\"./cmds/build.usage.kdl\"\ninclude file=\"./cmds/test.usage.kdl\"\n",
827 )
828 .unwrap();
829
830 let spec = Spec::parse_file(&root).unwrap();
831
832 assert_snapshot!(spec, @r#"
833 name ex
834 bin ex
835 cmd build {
836 flag "-v --verbose"
837 }
838 cmd test {
839 flag "-v --verbose"
840 }
841 "#);
842 }
843
844 #[test]
845 fn two_includes_may_not_declare_the_same_name() {
846 let dir = tempfile::tempdir().unwrap();
850 for (file, flag) in [("a.usage.kdl", "--json"), ("b.usage.kdl", "--yaml")] {
851 let body = format!("flagset \"output\" {{\n flag \"{flag}\"\n}}\n");
852 std::fs::write(dir.path().join(file), body).unwrap();
853 }
854 let root = dir.path().join("ex.usage.kdl");
855 std::fs::write(
856 &root,
857 "bin \"ex\"\ninclude file=\"./a.usage.kdl\"\ninclude file=\"./b.usage.kdl\"\n",
858 )
859 .unwrap();
860
861 let msg = err_file(&root);
862 assert!(
863 msg.contains("a flagset may be declared only once") && msg.contains("b.usage.kdl"),
864 "{msg}"
865 );
866 }
867
868 #[test]
869 fn a_set_holds_flags_and_says_so_about_arguments() {
870 let msg = err("flagset \"output\" {\n arg \"<file>\"\n}\n");
871 assert!(
872 msg.contains("a flagset holds flags, not arguments"),
873 "{msg}"
874 );
875 }
876
877 #[test]
878 fn a_set_rejects_what_it_has_no_meaning_for() {
879 let msg = err("flagset \"output\" {\n cmd \"nested\"\n}\n");
880 assert!(msg.contains("unsupported flagset key cmd"), "{msg}");
881 let msg = err("flagset \"output\" help=\"a set\" {\n flag \"--json\"\n}\n");
882 assert!(msg.contains("unsupported flagset prop help"), "{msg}");
883 }
884
885 #[test]
886 fn a_use_names_sets_and_nothing_else() {
887 let msg = err("use \"output\" {\n flag \"--json\"\n}\n");
888 assert!(
889 msg.contains("`use` names flagsets and holds nothing"),
890 "{msg}"
891 );
892 let msg = err("flagset \"o\" {\n flag \"--json\"\n}\nuse from=\"o\"\n");
893 assert!(msg.contains("expected 1.. arguments, got 0"), "{msg}");
894 }
895}