1pub mod arg;
2pub mod builder;
3pub mod choices;
4pub mod cmd;
5pub mod complete;
6pub mod config;
7mod context;
8pub mod data_types;
9pub mod effect;
10pub mod flag;
11pub mod helpers;
12pub mod mount;
13
14use indexmap::IndexMap;
15use kdl::{KdlDocument, KdlEntry, KdlNode};
16use log::{info, warn};
17use serde::Serialize;
18use std::fmt::{Display, Formatter};
19use std::iter::once;
20use std::path::Path;
21use std::str::FromStr;
22use xx::file;
23
24use crate::error::UsageErr;
25use crate::spec::cmd::{SpecCommand, SpecExample};
26use crate::spec::config::SpecConfig;
27use crate::spec::context::ParsingContext;
28use crate::spec::helpers::{string_entry, NodeHelper};
29use crate::{SpecArg, SpecComplete, SpecFlag};
30
31#[derive(Debug, Default, Clone, Serialize)]
32#[non_exhaustive]
33pub struct Spec {
34 pub name: String,
35 pub bin: String,
36 pub cmd: SpecCommand,
37 pub config: SpecConfig,
38 #[serde(skip_serializing_if = "Option::is_none")]
39 pub version: Option<String>,
40 pub usage: String,
41 pub complete: IndexMap<String, SpecComplete>,
42
43 #[serde(skip_serializing_if = "Option::is_none")]
44 pub source_code_link_template: Option<String>,
45 #[serde(skip_serializing_if = "Option::is_none")]
52 pub repository: Option<String>,
53 #[serde(skip_serializing_if = "Option::is_none")]
54 pub author: Option<String>,
55 #[serde(skip_serializing_if = "Option::is_none")]
56 pub about: Option<String>,
57 #[serde(skip_serializing_if = "Option::is_none")]
58 pub about_long: Option<String>,
59 #[serde(skip_serializing_if = "Option::is_none")]
60 pub about_md: Option<String>,
61 #[serde(skip_serializing_if = "Option::is_none")]
62 pub license: Option<String>,
63 #[serde(skip_serializing_if = "Option::is_none")]
64 pub before_help: Option<String>,
65 #[serde(skip_serializing_if = "Option::is_none")]
66 pub after_help: Option<String>,
67 #[serde(skip_serializing_if = "Option::is_none")]
68 pub before_help_long: Option<String>,
69 #[serde(skip_serializing_if = "Option::is_none")]
70 pub after_help_long: Option<String>,
71 #[serde(skip_serializing_if = "Option::is_none")]
72 pub disable_help: Option<bool>,
73 #[serde(skip_serializing_if = "Option::is_none")]
74 pub min_usage_version: Option<String>,
75 #[serde(skip_serializing_if = "Vec::is_empty")]
76 pub examples: Vec<SpecExample>,
77 #[serde(skip_serializing_if = "Option::is_none")]
80 pub default_subcommand: Option<String>,
81}
82
83impl Spec {
84 #[must_use = "parsing result should be used"]
92 pub fn parse_file(file: &Path) -> Result<Spec, UsageErr> {
93 Self::parse_file_with_metadata_inference(file, true)
94 }
95
96 fn parse_file_with_metadata_inference(
97 file: &Path,
98 infer_metadata_from_filename: bool,
99 ) -> Result<Spec, UsageErr> {
100 let spec = split_script(file)?;
101 let ctx = ParsingContext::new(file, &spec);
102 let mut schema = Self::parse(&ctx, &spec)?;
103 if infer_metadata_from_filename && schema.bin.is_empty() {
104 schema.bin = file
105 .file_name()
106 .and_then(|n| n.to_str())
107 .ok_or_else(|| UsageErr::InvalidPath(file.display().to_string()))?
108 .to_string();
109 }
110 if schema.name.is_empty() {
111 schema.name.clone_from(&schema.bin);
112 }
113 Ok(schema)
114 }
115 #[must_use = "parsing result should be used"]
121 pub fn parse_script(file: &Path) -> Result<Spec, UsageErr> {
122 let mut spec = Self::parse_script_with_path(&file::read_to_string(file)?, file)?;
123 if spec.bin.is_empty() {
124 spec.bin = file
125 .file_name()
126 .and_then(|n| n.to_str())
127 .ok_or_else(|| UsageErr::InvalidPath(file.display().to_string()))?
128 .to_string();
129 }
130 if spec.name.is_empty() {
131 spec.name.clone_from(&spec.bin);
132 }
133 Ok(spec)
134 }
135
136 #[must_use = "parsing result should be used"]
144 pub fn parse_script_str(input: &str) -> Result<Spec, UsageErr> {
145 Self::parse_script_with_path(input, Path::new(""))
146 }
147
148 fn parse_script_with_path(input: &str, file: &Path) -> Result<Spec, UsageErr> {
149 let raw = extract_usage_from_comments(input);
150 let ctx = ParsingContext::new(file, &raw);
151 Self::parse(&ctx, &raw)
152 }
153
154 #[deprecated]
155 pub fn parse_spec(input: &str) -> Result<Spec, UsageErr> {
156 Self::parse(&Default::default(), input)
157 }
158
159 pub fn is_empty(&self) -> bool {
160 self.name.is_empty()
161 && self.bin.is_empty()
162 && self.usage.is_empty()
163 && self.cmd.is_empty()
164 && self.config.is_empty()
165 && self.complete.is_empty()
166 && self.examples.is_empty()
167 }
168
169 pub(crate) fn parse(ctx: &ParsingContext, input: &str) -> Result<Spec, UsageErr> {
170 let kdl: KdlDocument = input
171 .parse()
172 .map_err(|err: kdl::KdlError| UsageErr::KdlError(err))?;
173 let mut schema = Self {
174 ..Default::default()
175 };
176 for node in kdl.nodes().iter().map(|n| NodeHelper::new(ctx, n)) {
177 match node.name() {
178 "name" => schema.name = node.arg(0)?.ensure_string()?,
179 "bin" => {
180 schema.bin = node.arg(0)?.ensure_string()?;
181 if schema.name.is_empty() {
182 schema.name.clone_from(&schema.bin);
183 }
184 }
185 "version" => schema.version = Some(node.arg(0)?.ensure_string()?),
186 "author" => schema.author = Some(node.arg(0)?.ensure_string()?),
187 "source_code_link_template" => {
188 schema.source_code_link_template = Some(node.arg(0)?.ensure_string()?)
189 }
190 "repository" => schema.repository = Some(node.arg(0)?.ensure_string()?),
191 "about" => schema.about = Some(node.arg(0)?.ensure_string()?),
192 "long_about" => schema.about_long = Some(node.arg(0)?.ensure_string()?),
193 "about_long" => schema.about_long = Some(node.arg(0)?.ensure_string()?),
194 "about_md" => schema.about_md = Some(node.arg(0)?.ensure_string()?),
195 "license" => schema.license = Some(node.arg(0)?.ensure_string()?),
196 "before_help" => schema.before_help = Some(node.arg(0)?.ensure_string()?),
197 "after_help" => schema.after_help = Some(node.arg(0)?.ensure_string()?),
198 "before_long_help" | "before_help_long" => {
199 schema.before_help_long = Some(node.arg(0)?.ensure_string()?)
200 }
201 "after_long_help" | "after_help_long" => {
202 schema.after_help_long = Some(node.arg(0)?.ensure_string()?)
203 }
204 "usage" => schema.usage = node.arg(0)?.ensure_string()?,
205 "arg" => schema.cmd.args.push(SpecArg::parse(ctx, &node)?),
206 "flag" => schema.cmd.flags.push(SpecFlag::parse(ctx, &node)?),
207 "cmd" => {
208 let node: SpecCommand = SpecCommand::parse(ctx, &node)?;
209 schema.cmd.subcommands.insert(node.name.to_string(), node);
210 }
211 "config" => schema.config = SpecConfig::parse(ctx, &node)?,
212 "complete" => {
213 let complete = SpecComplete::parse(ctx, &node)?;
214 schema.complete.insert(complete.name.clone(), complete);
215 }
216 "disable_help" => schema.disable_help = Some(node.arg(0)?.ensure_bool()?),
217 "min_usage_version" => {
218 let v = node.arg(0)?.ensure_string()?;
219 check_usage_version(&v);
220 schema.min_usage_version = Some(v);
221 }
222 "default_subcommand" => {
223 schema.default_subcommand = Some(node.arg(0)?.ensure_string()?)
224 }
225 "example" => {
226 let code = node.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?;
227 let mut example = SpecExample::new(code.trim().to_string());
228 for (k, v) in node.props() {
229 match k {
230 "header" => example.header = Some(v.ensure_string()?),
231 "help" => example.help = Some(v.ensure_string()?),
232 "lang" => example.lang = v.ensure_string()?,
233 k => bail_parse!(ctx, v.entry.span(), "unsupported example key {k}"),
234 }
235 }
236 schema.examples.push(example);
237 }
238 "include" => {
239 let file = node
240 .props()
241 .get("file")
242 .map(|v| v.ensure_string())
243 .transpose()?
244 .ok_or_else(|| ctx.build_err("missing file".into(), node.span()))?;
245 let file = Path::new(&file);
246 let file = match file.is_relative() {
247 true => ctx
248 .file
249 .parent()
250 .ok_or_else(|| {
251 let msg = if ctx.file.as_os_str().is_empty() {
252 "relative includes require a source file".to_string()
253 } else {
254 format!("cannot get parent of {}", ctx.file.display())
255 };
256 ctx.build_err(msg, node.span())
257 })?
258 .join(file),
259 false => file.to_path_buf(),
260 };
261 info!("include: {}", file.display());
262 let other = Self::parse_file_with_metadata_inference(&file, false)?;
263 schema.merge(other);
264 }
265 k => bail_parse!(ctx, node.node.name().span(), "unsupported spec key {k}"),
266 }
267 }
268 schema.cmd.name = if schema.bin.is_empty() {
269 schema.name.clone()
270 } else {
271 schema.bin.clone()
272 };
273 set_subcommand_ancestors(&mut schema.cmd, &[]);
274 Ok(schema)
275 }
276
277 pub fn merge(&mut self, other: Spec) {
278 macro_rules! merge_str {
279 ($field:ident) => {
280 if !other.$field.is_empty() {
281 self.$field = other.$field;
282 }
283 };
284 }
285 macro_rules! merge_opt {
286 ($field:ident) => {
287 if other.$field.is_some() {
288 self.$field = other.$field;
289 }
290 };
291 }
292 macro_rules! merge_extend {
293 ($field:ident) => {
294 if !other.$field.is_empty() {
295 self.$field.extend(other.$field);
296 }
297 };
298 }
299
300 merge_str!(name);
301 merge_str!(bin);
302 merge_str!(usage);
303 merge_opt!(about);
304 merge_opt!(source_code_link_template);
305 merge_opt!(repository);
306 merge_opt!(version);
307 merge_opt!(author);
308 merge_opt!(about_long);
309 merge_opt!(about_md);
310 merge_opt!(license);
311 merge_opt!(before_help);
312 merge_opt!(after_help);
313 merge_opt!(before_help_long);
314 merge_opt!(after_help_long);
315 merge_opt!(disable_help);
316 merge_opt!(min_usage_version);
317 merge_opt!(default_subcommand);
318 merge_extend!(complete);
319 merge_extend!(examples);
320
321 if !other.config.is_empty() {
322 self.config.merge(&other.config);
323 }
324 self.cmd.merge(other.cmd);
325 }
326}
327
328fn check_usage_version(version: &str) {
329 let cur = versions::Versioning::new(env!("CARGO_PKG_VERSION")).unwrap();
330 match versions::Versioning::new(version) {
331 Some(v) => {
332 if cur < v {
333 warn!(
334 "This usage spec requires at least version {version}, but you are using version {cur} of usage"
335 );
336 }
337 }
338 _ => warn!("Invalid version: {version}"),
339 }
340}
341
342fn split_script(file: &Path) -> Result<String, UsageErr> {
343 let full = file::read_to_string(file)?;
344 if full.starts_with("#!") {
346 let usage_regex = xx::regex!(r"^(?:#|//|::)(?:USAGE| ?\[USAGE\])");
347 if full.lines().any(|l| usage_regex.is_match(l)) {
348 return Ok(extract_usage_from_comments(&full));
349 }
350 }
351 Ok(full)
353}
354
355fn extract_usage_from_comments(full: &str) -> String {
356 let usage_regex = xx::regex!(r"^(?:#|//|::)(?:USAGE| ?\[USAGE\])(.*)$");
357 let blank_comment_regex = xx::regex!(r"^(?:#|//|::)\s*$");
358 let mut usage = vec![];
359 let mut found = false;
360 for line in full.lines() {
361 if let Some(captures) = usage_regex.captures(line) {
362 found = true;
363 let content = captures.get(1).map_or("", |m| m.as_str());
364 usage.push(content.trim());
365 } else if found {
366 if blank_comment_regex.is_match(line) {
368 continue;
369 }
370 break;
372 }
373 }
374 usage.join("\n")
375}
376
377fn set_subcommand_ancestors(cmd: &mut SpecCommand, ancestors: &[String]) {
378 for subcmd in cmd.subcommands.values_mut() {
379 subcmd.full_cmd = ancestors
380 .iter()
381 .cloned()
382 .chain(once(subcmd.name.clone()))
383 .collect();
384 let child_ancestors = subcmd.full_cmd.clone();
385 set_subcommand_ancestors(subcmd, &child_ancestors);
386 }
387 if cmd.usage.is_empty() {
388 cmd.usage = cmd.usage();
389 }
390}
391
392impl Display for Spec {
393 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
394 let mut doc = KdlDocument::new();
395 let nodes = &mut doc.nodes_mut();
396 if !self.name.is_empty() {
397 let mut node = KdlNode::new("name");
398 node.push(string_entry(None, &self.name));
399 nodes.push(node);
400 }
401 if !self.bin.is_empty() {
402 let mut node = KdlNode::new("bin");
403 node.push(string_entry(None, &self.bin));
404 nodes.push(node);
405 }
406 if let Some(version) = &self.version {
407 let mut node = KdlNode::new("version");
408 node.push(string_entry(None, version));
409 nodes.push(node);
410 }
411 if let Some(author) = &self.author {
412 let mut node = KdlNode::new("author");
413 node.push(string_entry(None, author));
414 nodes.push(node);
415 }
416 if let Some(about) = &self.about {
417 let mut node = KdlNode::new("about");
418 node.push(string_entry(None, about));
419 nodes.push(node);
420 }
421 if let Some(source_code_link_template) = &self.source_code_link_template {
422 let mut node = KdlNode::new("source_code_link_template");
423 node.push(string_entry(None, source_code_link_template));
424 nodes.push(node);
425 }
426 if let Some(repository) = &self.repository {
427 let mut node = KdlNode::new("repository");
428 node.push(string_entry(None, repository));
429 nodes.push(node);
430 }
431 if let Some(about_md) = &self.about_md {
432 let mut node = KdlNode::new("about_md");
433 node.push(string_entry(None, about_md));
434 nodes.push(node);
435 }
436 if let Some(long_about) = &self.about_long {
437 let mut node = KdlNode::new("long_about");
438 node.push(string_entry(None, long_about));
439 nodes.push(node);
440 }
441 if let Some(license) = &self.license {
442 let mut node = KdlNode::new("license");
443 node.push(string_entry(None, license));
444 nodes.push(node);
445 }
446 if let Some(before_help) = &self.before_help {
447 let mut node = KdlNode::new("before_help");
448 node.push(string_entry(None, before_help));
449 nodes.push(node);
450 }
451 if let Some(after_help) = &self.after_help {
452 let mut node = KdlNode::new("after_help");
453 node.push(string_entry(None, after_help));
454 nodes.push(node);
455 }
456 if let Some(before_help_long) = &self.before_help_long {
457 let mut node = KdlNode::new("before_long_help");
458 node.push(string_entry(None, before_help_long));
459 nodes.push(node);
460 }
461 if let Some(after_help_long) = &self.after_help_long {
462 let mut node = KdlNode::new("after_long_help");
463 node.push(string_entry(None, after_help_long));
464 nodes.push(node);
465 }
466 if let Some(disable_help) = self.disable_help {
467 let mut node = KdlNode::new("disable_help");
468 node.push(KdlEntry::new(disable_help));
469 nodes.push(node);
470 }
471 if let Some(min_usage_version) = &self.min_usage_version {
472 let mut node = KdlNode::new("min_usage_version");
473 node.push(string_entry(None, min_usage_version));
474 nodes.push(node);
475 }
476 if let Some(default_subcommand) = &self.default_subcommand {
477 let mut node = KdlNode::new("default_subcommand");
478 node.push(string_entry(None, default_subcommand));
479 nodes.push(node);
480 }
481 if !self.usage.is_empty() {
482 let mut node = KdlNode::new("usage");
483 node.push(string_entry(None, &self.usage));
484 nodes.push(node);
485 }
486 for flag in self.cmd.flags.iter() {
487 nodes.push(flag.into());
488 }
489 for arg in self.cmd.args.iter() {
490 nodes.push(arg.into());
491 }
492 for example in self.examples.iter() {
493 nodes.push(example.into());
494 }
495 for complete in self.complete.values() {
496 nodes.push(complete.into());
497 }
498 for complete in self.cmd.complete.values() {
499 nodes.push(complete.into());
500 }
501 for cmd in self.cmd.subcommands.values() {
502 nodes.push(cmd.into())
503 }
504 if !self.config.is_empty() {
505 nodes.push((&self.config).into());
506 }
507 doc.autoformat_config(&kdl::FormatConfigBuilder::new().build());
508 write!(f, "{doc}")
509 }
510}
511
512impl FromStr for Spec {
513 type Err = UsageErr;
514
515 fn from_str(s: &str) -> Result<Self, Self::Err> {
516 Self::parse(&Default::default(), s)
517 }
518}
519
520#[cfg(feature = "clap")]
521impl From<&clap::Command> for Spec {
522 fn from(cmd: &clap::Command) -> Self {
523 Spec {
524 name: cmd.get_name().to_string(),
525 bin: cmd.get_bin_name().unwrap_or(cmd.get_name()).to_string(),
526 cmd: cmd.into(),
527 version: cmd.get_version().map(|v| v.to_string()),
528 about: cmd.get_about().map(|a| a.to_string()),
529 about_long: cmd.get_long_about().map(|a| a.to_string()),
530 usage: cmd.clone().render_usage().to_string(),
531 ..Default::default()
532 }
533 }
534}
535
536#[inline]
537pub fn is_true(b: &bool) -> bool {
538 *b
539}
540
541#[inline]
542pub fn is_false(b: &bool) -> bool {
543 !is_true(b)
544}
545
546#[cfg(test)]
547mod tests {
548 use super::*;
549 use insta::assert_snapshot;
550
551 #[test]
552 fn test_display() {
553 let spec = Spec::parse(
554 &Default::default(),
555 r#"
556name "Usage CLI"
557bin "usage"
558arg "arg1"
559flag "-f --force" global=#true
560cmd "config" {
561 cmd "set" {
562 arg "key" help="Key to set"
563 arg "value"
564 }
565}
566complete "file" run="ls" descriptions=#true
567 "#,
568 )
569 .unwrap();
570 assert_snapshot!(spec, @r#"
571 name "Usage CLI"
572 bin usage
573 flag "-f --force" global=#true
574 arg <arg1>
575 complete file run=ls descriptions=#true
576 cmd config {
577 cmd set {
578 arg <key> help="Key to set"
579 arg <value>
580 }
581 }
582 "#);
583 }
584
585 #[test]
586 fn test_repository_round_trips() {
587 let spec = Spec::parse(
588 &Default::default(),
589 r#"
590bin "mise"
591repository "https://github.com/jdx/mise"
592source_code_link_template "https://github.com/jdx/mise/blob/main/src/cli/{{path}}.rs"
593 "#,
594 )
595 .unwrap();
596 assert_eq!(
597 spec.repository.as_deref(),
598 Some("https://github.com/jdx/mise")
599 );
600 assert_snapshot!(spec, @r#"
603 name mise
604 bin mise
605 source_code_link_template "https://github.com/jdx/mise/blob/main/src/cli/{{path}}.rs"
606 repository "https://github.com/jdx/mise"
607 "#);
608 }
609
610 #[test]
611 fn test_repository_merges_like_the_other_optionals() {
612 let mut generated = Spec::parse(&Default::default(), r#"bin "mise""#).unwrap();
615 let extra = Spec::parse(
616 &Default::default(),
617 r#"repository "https://github.com/jdx/mise""#,
618 )
619 .unwrap();
620 generated.merge(extra);
621 assert_eq!(
622 generated.repository.as_deref(),
623 Some("https://github.com/jdx/mise")
624 );
625 }
626
627 #[test]
628 #[cfg(feature = "clap")]
629 fn test_clap() {
630 let cmd = clap::Command::new("test");
631 assert_snapshot!(Spec::from(&cmd), @r#"
632 name test
633 bin test
634 usage "Usage: test"
635 "#);
636 }
637
638 macro_rules! extract_usage_tests {
639 ($($name:ident: $input:expr, $expected:expr,)*) => {
640 $(
641 #[test]
642 fn $name() {
643 let result = extract_usage_from_comments($input);
644 let expected = $expected.trim_start_matches('\n').trim_end();
645 assert_eq!(result, expected);
646 }
647 )*
648 }
649 }
650
651 extract_usage_tests! {
652 test_extract_usage_from_comments_original_hash:
653 r#"
654#!/bin/bash
655#USAGE bin "test"
656#USAGE flag "--foo" help="test"
657echo "hello"
658 "#,
659 r#"
660bin "test"
661flag "--foo" help="test"
662 "#,
663
664 test_extract_usage_from_comments_original_double_slash:
665 r#"
666#!/usr/bin/env node
667//USAGE bin "test"
668//USAGE flag "--foo" help="test"
669console.log("hello");
670 "#,
671 r#"
672bin "test"
673flag "--foo" help="test"
674 "#,
675
676 test_extract_usage_from_comments_bracket_with_space:
677 r#"
678#!/bin/bash
679# [USAGE] bin "test"
680# [USAGE] flag "--foo" help="test"
681echo "hello"
682 "#,
683 r#"
684bin "test"
685flag "--foo" help="test"
686 "#,
687
688 test_extract_usage_from_comments_bracket_no_space:
689 r#"
690#!/bin/bash
691#[USAGE] bin "test"
692#[USAGE] flag "--foo" help="test"
693echo "hello"
694 "#,
695 r#"
696bin "test"
697flag "--foo" help="test"
698 "#,
699
700 test_extract_usage_from_comments_double_slash_bracket_with_space:
701 r#"
702#!/usr/bin/env node
703// [USAGE] bin "test"
704// [USAGE] flag "--foo" help="test"
705console.log("hello");
706 "#,
707 r#"
708bin "test"
709flag "--foo" help="test"
710 "#,
711
712 test_extract_usage_from_comments_double_slash_bracket_no_space:
713 r#"
714#!/usr/bin/env node
715//[USAGE] bin "test"
716//[USAGE] flag "--foo" help="test"
717console.log("hello");
718 "#,
719 r#"
720bin "test"
721flag "--foo" help="test"
722 "#,
723
724 test_extract_usage_from_comments_stops_at_gap:
725 r#"
726#!/bin/bash
727#USAGE bin "test"
728#USAGE flag "--foo" help="test"
729
730#USAGE flag "--bar" help="should not be included"
731echo "hello"
732 "#,
733 r#"
734bin "test"
735flag "--foo" help="test"
736 "#,
737
738 test_extract_usage_from_comments_with_content_after_marker:
739 r#"
740#!/bin/bash
741# [USAGE] bin "test"
742# [USAGE] flag "--verbose" help="verbose mode"
743# [USAGE] arg "input" help="input file"
744echo "hello"
745 "#,
746 r#"
747bin "test"
748flag "--verbose" help="verbose mode"
749arg "input" help="input file"
750 "#,
751
752 test_extract_usage_from_comments_double_colon_original:
753 r#"
754::USAGE bin "test"
755::USAGE flag "--foo" help="test"
756echo "hello"
757 "#,
758 r#"
759bin "test"
760flag "--foo" help="test"
761 "#,
762
763 test_extract_usage_from_comments_double_colon_bracket_with_space:
764 r#"
765:: [USAGE] bin "test"
766:: [USAGE] flag "--foo" help="test"
767echo "hello"
768 "#,
769 r#"
770bin "test"
771flag "--foo" help="test"
772 "#,
773
774 test_extract_usage_from_comments_double_colon_bracket_no_space:
775 r#"
776::[USAGE] bin "test"
777::[USAGE] flag "--foo" help="test"
778echo "hello"
779 "#,
780 r#"
781bin "test"
782flag "--foo" help="test"
783 "#,
784
785 test_extract_usage_from_comments_double_colon_stops_at_gap:
786 r#"
787::USAGE bin "test"
788::USAGE flag "--foo" help="test"
789
790::USAGE flag "--bar" help="should not be included"
791echo "hello"
792 "#,
793 r#"
794bin "test"
795flag "--foo" help="test"
796 "#,
797
798 test_extract_usage_from_comments_double_colon_with_content_after_marker:
799 r#"
800::USAGE bin "test"
801::USAGE flag "--verbose" help="verbose mode"
802::USAGE arg "input" help="input file"
803echo "hello"
804 "#,
805 r#"
806bin "test"
807flag "--verbose" help="verbose mode"
808arg "input" help="input file"
809 "#,
810
811 test_extract_usage_from_comments_double_colon_bracket_with_space_multiple_lines:
812 r#"
813:: [USAGE] bin "myapp"
814:: [USAGE] flag "--config <file>" help="config file"
815:: [USAGE] flag "--verbose" help="verbose output"
816:: [USAGE] arg "input" help="input file"
817:: [USAGE] arg "[output]" help="output file" required=#false
818echo "done"
819 "#,
820 r#"
821bin "myapp"
822flag "--config <file>" help="config file"
823flag "--verbose" help="verbose output"
824arg "input" help="input file"
825arg "[output]" help="output file" required=#false
826 "#,
827
828 test_extract_usage_from_comments_empty:
829 r#"
830#!/bin/bash
831echo "hello"
832 "#,
833 "",
834
835 test_extract_usage_from_comments_lowercase_usage:
836 r#"
837#!/bin/bash
838#usage bin "test"
839#usage flag "--foo" help="test"
840echo "hello"
841 "#,
842 "",
843
844 test_extract_usage_from_comments_mixed_case_usage:
845 r#"
846#!/bin/bash
847#Usage bin "test"
848#Usage flag "--foo" help="test"
849echo "hello"
850 "#,
851 "",
852
853 test_extract_usage_from_comments_space_before_usage:
854 r#"
855#!/bin/bash
856# USAGE bin "test"
857# USAGE flag "--foo" help="test"
858echo "hello"
859 "#,
860 "",
861
862 test_extract_usage_from_comments_double_slash_lowercase:
863 r#"
864#!/usr/bin/env node
865//usage bin "test"
866//usage flag "--foo" help="test"
867console.log("hello");
868 "#,
869 "",
870
871 test_extract_usage_from_comments_double_slash_mixed_case:
872 r#"
873#!/usr/bin/env node
874//Usage bin "test"
875//Usage flag "--foo" help="test"
876console.log("hello");
877 "#,
878 "",
879
880 test_extract_usage_from_comments_double_slash_space_before_usage:
881 r#"
882#!/usr/bin/env node
883// USAGE bin "test"
884// USAGE flag "--foo" help="test"
885console.log("hello");
886 "#,
887 "",
888
889 test_extract_usage_from_comments_bracket_lowercase:
890 r#"
891#!/bin/bash
892#[usage] bin "test"
893#[usage] flag "--foo" help="test"
894echo "hello"
895 "#,
896 "",
897
898 test_extract_usage_from_comments_bracket_mixed_case:
899 r#"
900#!/bin/bash
901#[Usage] bin "test"
902#[Usage] flag "--foo" help="test"
903echo "hello"
904 "#,
905 "",
906
907 test_extract_usage_from_comments_bracket_space_lowercase:
908 r#"
909#!/bin/bash
910# [usage] bin "test"
911# [usage] flag "--foo" help="test"
912echo "hello"
913 "#,
914 "",
915
916 test_extract_usage_from_comments_double_colon_lowercase:
917 r#"
918::usage bin "test"
919::usage flag "--foo" help="test"
920echo "hello"
921 "#,
922 "",
923
924 test_extract_usage_from_comments_double_colon_mixed_case:
925 r#"
926::Usage bin "test"
927::Usage flag "--foo" help="test"
928echo "hello"
929 "#,
930 "",
931
932 test_extract_usage_from_comments_double_colon_space_before_usage:
933 r#"
934:: USAGE bin "test"
935:: USAGE flag "--foo" help="test"
936echo "hello"
937 "#,
938 "",
939
940 test_extract_usage_from_comments_double_colon_bracket_lowercase:
941 r#"
942::[usage] bin "test"
943::[usage] flag "--foo" help="test"
944echo "hello"
945 "#,
946 "",
947
948 test_extract_usage_from_comments_double_colon_bracket_mixed_case:
949 r#"
950::[Usage] bin "test"
951::[Usage] flag "--foo" help="test"
952echo "hello"
953 "#,
954 "",
955
956 test_extract_usage_from_comments_double_colon_bracket_space_lowercase:
957 r#"
958:: [usage] bin "test"
959:: [usage] flag "--foo" help="test"
960echo "hello"
961 "#,
962 "",
963 }
964
965 #[test]
966 fn test_spec_with_examples() {
967 let spec = Spec::parse(
968 &Default::default(),
969 r#"
970name "demo"
971bin "demo"
972example "demo --help" header="Getting help" help="Display help information"
973example "demo --version" header="Check version"
974 "#,
975 )
976 .unwrap();
977
978 assert_eq!(spec.examples.len(), 2);
979
980 assert_eq!(spec.examples[0].code, "demo --help");
981 assert_eq!(spec.examples[0].header, Some("Getting help".to_string()));
982 assert_eq!(
983 spec.examples[0].help,
984 Some("Display help information".to_string())
985 );
986
987 assert_eq!(spec.examples[1].code, "demo --version");
988 assert_eq!(spec.examples[1].header, Some("Check version".to_string()));
989 assert_eq!(spec.examples[1].help, None);
990 }
991
992 #[test]
993 fn test_spec_examples_display() {
994 let spec = Spec::parse(
995 &Default::default(),
996 r#"
997name "demo"
998bin "demo"
999example "demo --help" header="Getting help" help="Show help"
1000example "demo --version"
1001 "#,
1002 )
1003 .unwrap();
1004
1005 let output = format!("{}", spec);
1006 assert!(
1007 output.contains("example \"demo --help\" header=\"Getting help\" help=\"Show help\"")
1008 );
1009 assert!(output.contains("example \"demo --version\""));
1010 }
1011
1012 #[test]
1013 fn test_parse_script_str() {
1014 let spec = Spec::parse_script_str(
1015 r#"
1016#!/bin/bash
1017#USAGE bin "test"
1018#USAGE flag "--foo" help="test"
1019echo "hello"
1020 "#,
1021 )
1022 .unwrap();
1023
1024 assert_eq!(spec.bin, "test");
1025 assert_eq!(spec.name, "test");
1026 assert_eq!(spec.cmd.flags.len(), 1);
1027 assert_eq!(spec.cmd.flags[0].long, ["foo"]);
1028 }
1029
1030 #[test]
1031 fn test_parse_script_str_rejects_relative_includes() {
1032 let err = Spec::parse_script_str(r#"#USAGE include file="relative.usage.kdl""#)
1033 .expect_err("relative includes need a source path");
1034
1035 match err {
1036 UsageErr::InvalidInput(msg, _, _) => {
1037 assert_eq!(msg, "relative includes require a source file");
1038 }
1039 err => panic!("unexpected error: {err:?}"),
1040 }
1041 }
1042
1043 #[test]
1044 fn test_include_does_not_infer_metadata_from_included_filename() {
1045 let dir = tempfile::tempdir().unwrap();
1046 let included = dir.path().join("overrides.usage.kdl");
1047 let root = dir.path().join("my-script.usage.kdl");
1048 std::fs::write(&included, "").unwrap();
1049 std::fs::write(&root, "include file=\"./overrides.usage.kdl\"\n").unwrap();
1050
1051 let spec = Spec::parse_file(&root).unwrap();
1052
1053 assert_eq!(spec.name, "my-script.usage.kdl");
1054 assert_eq!(spec.bin, "my-script.usage.kdl");
1055 assert!(spec.cmd.name.is_empty());
1056 }
1057}