1pub mod arg;
2pub mod choices;
3pub mod cmd;
4pub mod complete;
5pub mod config;
6mod context;
7mod data_types;
8pub mod flag;
9pub mod helpers;
10pub mod mount;
11
12use indexmap::IndexMap;
13use kdl::{KdlDocument, KdlEntry, KdlNode, KdlValue};
14use log::{info, warn};
15use serde::Serialize;
16use std::fmt::{Display, Formatter};
17use std::iter::once;
18use std::path::Path;
19use std::str::FromStr;
20use xx::file;
21
22use crate::error::UsageErr;
23use crate::spec::cmd::SpecCommand;
24use crate::spec::config::SpecConfig;
25use crate::spec::context::ParsingContext;
26use crate::spec::helpers::NodeHelper;
27use crate::{SpecArg, SpecComplete, SpecFlag};
28
29#[derive(Debug, Default, Clone, Serialize)]
30pub struct Spec {
31 pub name: String,
32 pub bin: String,
33 pub cmd: SpecCommand,
34 pub config: SpecConfig,
35 #[serde(skip_serializing_if = "Option::is_none")]
36 pub version: Option<String>,
37 pub usage: String,
38 pub complete: IndexMap<String, SpecComplete>,
39
40 #[serde(skip_serializing_if = "Option::is_none")]
41 pub source_code_link_template: Option<String>,
42 #[serde(skip_serializing_if = "Option::is_none")]
43 pub author: Option<String>,
44 #[serde(skip_serializing_if = "Option::is_none")]
45 pub about: Option<String>,
46 #[serde(skip_serializing_if = "Option::is_none")]
47 pub about_long: Option<String>,
48 #[serde(skip_serializing_if = "Option::is_none")]
49 pub about_md: Option<String>,
50 #[serde(skip_serializing_if = "Option::is_none")]
51 pub disable_help: Option<bool>,
52 #[serde(skip_serializing_if = "Option::is_none")]
53 pub min_usage_version: Option<String>,
54}
55
56impl Spec {
57 pub fn parse_file(file: &Path) -> Result<(Spec, String), UsageErr> {
58 let (spec, body) = split_script(file)?;
59 let ctx = ParsingContext::new(file, &spec);
60 let mut schema = Self::parse(&ctx, &spec)?;
61 if schema.bin.is_empty() {
62 schema.bin = file.file_name().unwrap().to_str().unwrap().to_string();
63 }
64 if schema.name.is_empty() {
65 schema.name.clone_from(&schema.bin);
66 }
67 Ok((schema, body))
68 }
69 pub fn parse_script(file: &Path) -> Result<Spec, UsageErr> {
70 let raw = extract_usage_from_comments(&file::read_to_string(file)?);
71 let ctx = ParsingContext::new(file, &raw);
72 let mut spec = Self::parse(&ctx, &raw)?;
73 if spec.bin.is_empty() {
74 spec.bin = file.file_name().unwrap().to_str().unwrap().to_string();
75 }
76 if spec.name.is_empty() {
77 spec.name.clone_from(&spec.bin);
78 }
79 Ok(spec)
80 }
81
82 #[deprecated]
83 pub fn parse_spec(input: &str) -> Result<Spec, UsageErr> {
84 Self::parse(&Default::default(), input)
85 }
86
87 pub fn is_empty(&self) -> bool {
88 self.name.is_empty()
89 && self.bin.is_empty()
90 && self.usage.is_empty()
91 && self.cmd.is_empty()
92 && self.config.is_empty()
93 && self.complete.is_empty()
94 }
95
96 pub(crate) fn parse(ctx: &ParsingContext, input: &str) -> Result<Spec, UsageErr> {
97 let kdl: KdlDocument = input
98 .parse()
99 .map_err(|err: kdl::KdlError| UsageErr::KdlError(err))?;
100 let mut schema = Self {
101 ..Default::default()
102 };
103 for node in kdl.nodes().iter().map(|n| NodeHelper::new(ctx, n)) {
104 match node.name() {
105 "name" => schema.name = node.arg(0)?.ensure_string()?,
106 "bin" => {
107 schema.bin = node.arg(0)?.ensure_string()?;
108 if schema.name.is_empty() {
109 schema.name.clone_from(&schema.bin);
110 }
111 }
112 "version" => schema.version = Some(node.arg(0)?.ensure_string()?),
113 "author" => schema.author = Some(node.arg(0)?.ensure_string()?),
114 "source_code_link_template" => {
115 schema.source_code_link_template = Some(node.arg(0)?.ensure_string()?)
116 }
117 "about" => schema.about = Some(node.arg(0)?.ensure_string()?),
118 "long_about" => schema.about_long = Some(node.arg(0)?.ensure_string()?),
119 "about_long" => schema.about_long = Some(node.arg(0)?.ensure_string()?),
120 "about_md" => schema.about_md = Some(node.arg(0)?.ensure_string()?),
121 "usage" => schema.usage = node.arg(0)?.ensure_string()?,
122 "arg" => schema.cmd.args.push(SpecArg::parse(ctx, &node)?),
123 "flag" => schema.cmd.flags.push(SpecFlag::parse(ctx, &node)?),
124 "cmd" => {
125 let node: SpecCommand = SpecCommand::parse(ctx, &node)?;
126 schema.cmd.subcommands.insert(node.name.to_string(), node);
127 }
128 "config" => schema.config = SpecConfig::parse(ctx, &node)?,
129 "complete" => {
130 let complete = SpecComplete::parse(ctx, &node)?;
131 schema.complete.insert(complete.name.clone(), complete);
132 }
133 "disable_help" => schema.disable_help = Some(node.arg(0)?.ensure_bool()?),
134 "min_usage_version" => {
135 let v = node.arg(0)?.ensure_string()?;
136 check_usage_version(&v);
137 schema.min_usage_version = Some(v);
138 }
139 "include" => {
140 let file = node
141 .props()
142 .get("file")
143 .map(|v| v.ensure_string())
144 .transpose()?
145 .ok_or_else(|| ctx.build_err("missing file".into(), node.span()))?;
146 let file = Path::new(&file);
147 let file = match file.is_relative() {
148 true => ctx.file.parent().unwrap().join(file),
149 false => file.to_path_buf(),
150 };
151 info!("include: {}", file.display());
152 let (other, _) = Self::parse_file(&file)?;
153 schema.merge(other);
154 }
155 k => bail_parse!(ctx, node.node.name().span(), "unsupported spec key {k}"),
156 }
157 }
158 schema.cmd.name = if schema.bin.is_empty() {
159 schema.name.clone()
160 } else {
161 schema.bin.clone()
162 };
163 set_subcommand_ancestors(&mut schema.cmd, &[]);
164 Ok(schema)
165 }
166
167 pub fn merge(&mut self, other: Spec) {
168 if !other.name.is_empty() {
169 self.name = other.name;
170 }
171 if !other.bin.is_empty() {
172 self.bin = other.bin;
173 }
174 if !other.usage.is_empty() {
175 self.usage = other.usage;
176 }
177 if other.about.is_some() {
178 self.about = other.about;
179 }
180 if other.source_code_link_template.is_some() {
181 self.source_code_link_template = other.source_code_link_template;
182 }
183 if other.version.is_some() {
184 self.version = other.version;
185 }
186 if other.author.is_some() {
187 self.author = other.author;
188 }
189 if other.about_long.is_some() {
190 self.about_long = other.about_long;
191 }
192 if other.about_md.is_some() {
193 self.about_md = other.about_md;
194 }
195 if !other.config.is_empty() {
196 self.config.merge(&other.config);
197 }
198 if !other.complete.is_empty() {
199 self.complete.extend(other.complete);
200 }
201 if other.disable_help.is_some() {
202 self.disable_help = other.disable_help;
203 }
204 if other.min_usage_version.is_some() {
205 self.min_usage_version = other.min_usage_version;
206 }
207 self.cmd.merge(other.cmd);
208 }
209}
210
211fn check_usage_version(version: &str) {
212 let cur = versions::Versioning::new(env!("CARGO_PKG_VERSION")).unwrap();
213 match versions::Versioning::new(version) {
214 Some(v) => {
215 if cur < v {
216 warn!(
217 "This usage spec requires at least version {version}, but you are using version {cur} of usage"
218 );
219 }
220 }
221 _ => warn!("Invalid version: {version}"),
222 }
223}
224
225fn split_script(file: &Path) -> Result<(String, String), UsageErr> {
226 let full = file::read_to_string(file)?;
227 if full.starts_with("#!") {
228 let usage_regex = xx::regex!(r"^(?:#|//|::)(?:USAGE| ?\[USAGE\])");
229 if full.lines().any(|l| usage_regex.is_match(l)) {
230 return Ok((extract_usage_from_comments(&full), full));
231 }
232 }
233 let schema = full.strip_prefix("#!/usr/bin/env usage\n").unwrap_or(&full);
234 let (schema, body) = schema.split_once("\n#!").unwrap_or((schema, ""));
235 let schema = schema
236 .trim()
237 .lines()
238 .filter(|l| !l.starts_with('#'))
239 .collect::<Vec<_>>()
240 .join("\n");
241 let body = format!("#!{body}");
242 Ok((schema, body))
243}
244
245fn extract_usage_from_comments(full: &str) -> String {
246 let usage_regex = xx::regex!(r"^(?:#|//|::)(?:USAGE| ?\[USAGE\])(.*)$");
247 let mut usage = vec![];
248 let mut found = false;
249 for line in full.lines() {
250 if let Some(captures) = usage_regex.captures(line) {
251 found = true;
252 let content = captures.get(1).map_or("", |m| m.as_str());
253 usage.push(content.trim());
254 } else if found {
255 break;
257 }
258 }
259 usage.join("\n")
260}
261
262fn set_subcommand_ancestors(cmd: &mut SpecCommand, ancestors: &[String]) {
263 let ancestors = ancestors.to_vec();
264 for subcmd in cmd.subcommands.values_mut() {
265 subcmd.full_cmd = ancestors
266 .clone()
267 .into_iter()
268 .chain(once(subcmd.name.clone()))
269 .collect();
270 set_subcommand_ancestors(subcmd, &subcmd.full_cmd.clone());
271 }
272 if cmd.usage.is_empty() {
273 cmd.usage = cmd.usage();
274 }
275}
276
277impl Display for Spec {
278 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
279 let mut doc = KdlDocument::new();
280 let nodes = &mut doc.nodes_mut();
281 if !self.name.is_empty() {
282 let mut node = KdlNode::new("name");
283 node.push(KdlEntry::new(self.name.clone()));
284 nodes.push(node);
285 }
286 if !self.bin.is_empty() {
287 let mut node = KdlNode::new("bin");
288 node.push(KdlEntry::new(self.bin.clone()));
289 nodes.push(node);
290 }
291 if let Some(version) = &self.version {
292 let mut node = KdlNode::new("version");
293 node.push(KdlEntry::new(version.clone()));
294 nodes.push(node);
295 }
296 if let Some(author) = &self.author {
297 let mut node = KdlNode::new("author");
298 node.push(KdlEntry::new(author.clone()));
299 nodes.push(node);
300 }
301 if let Some(about) = &self.about {
302 let mut node = KdlNode::new("about");
303 node.push(KdlEntry::new(about.clone()));
304 nodes.push(node);
305 }
306 if let Some(source_code_link_template) = &self.source_code_link_template {
307 let mut node = KdlNode::new("source_code_link_template");
308 node.push(KdlEntry::new(source_code_link_template.clone()));
309 nodes.push(node);
310 }
311 if let Some(about_md) = &self.about_md {
312 let mut node = KdlNode::new("about_md");
313 node.push(KdlEntry::new(KdlValue::String(about_md.clone())));
314 nodes.push(node);
315 }
316 if let Some(long_about) = &self.about_long {
317 let mut node = KdlNode::new("long_about");
318 node.push(KdlEntry::new(KdlValue::String(long_about.clone())));
319 nodes.push(node);
320 }
321 if let Some(disable_help) = self.disable_help {
322 let mut node = KdlNode::new("disable_help");
323 node.push(KdlEntry::new(disable_help));
324 nodes.push(node);
325 }
326 if let Some(min_usage_version) = &self.min_usage_version {
327 let mut node = KdlNode::new("min_usage_version");
328 node.push(KdlEntry::new(min_usage_version.clone()));
329 nodes.push(node);
330 }
331 if !self.usage.is_empty() {
332 let mut node = KdlNode::new("usage");
333 node.push(KdlEntry::new(self.usage.clone()));
334 nodes.push(node);
335 }
336 for flag in self.cmd.flags.iter() {
337 nodes.push(flag.into());
338 }
339 for arg in self.cmd.args.iter() {
340 nodes.push(arg.into());
341 }
342 for complete in self.complete.values() {
343 nodes.push(complete.into());
344 }
345 for complete in self.cmd.complete.values() {
346 nodes.push(complete.into());
347 }
348 for cmd in self.cmd.subcommands.values() {
349 nodes.push(cmd.into())
350 }
351 if !self.config.is_empty() {
352 nodes.push((&self.config).into());
353 }
354 doc.autoformat_config(&kdl::FormatConfigBuilder::new().build());
355 write!(f, "{doc}")
356 }
357}
358
359impl FromStr for Spec {
360 type Err = UsageErr;
361
362 fn from_str(s: &str) -> Result<Self, Self::Err> {
363 Self::parse(&Default::default(), s)
364 }
365}
366
367#[cfg(feature = "clap")]
368impl From<&clap::Command> for Spec {
369 fn from(cmd: &clap::Command) -> Self {
370 Spec {
371 name: cmd.get_name().to_string(),
372 bin: cmd.get_bin_name().unwrap_or(cmd.get_name()).to_string(),
373 cmd: cmd.into(),
374 version: cmd.get_version().map(|v| v.to_string()),
375 about: cmd.get_about().map(|a| a.to_string()),
376 about_long: cmd.get_long_about().map(|a| a.to_string()),
377 usage: cmd.clone().render_usage().to_string(),
378 ..Default::default()
379 }
380 }
381}
382
383#[inline]
384pub fn is_true(b: &bool) -> bool {
385 *b
386}
387
388#[inline]
389pub fn is_false(b: &bool) -> bool {
390 !is_true(b)
391}
392
393#[cfg(test)]
394mod tests {
395 use super::*;
396 use insta::assert_snapshot;
397
398 #[test]
399 fn test_display() {
400 let spec = Spec::parse(
401 &Default::default(),
402 r#"
403name "Usage CLI"
404bin "usage"
405arg "arg1"
406flag "-f --force" global=#true
407cmd "config" {
408 cmd "set" {
409 arg "key" help="Key to set"
410 arg "value"
411 }
412}
413complete "file" run="ls" descriptions=#true
414 "#,
415 )
416 .unwrap();
417 assert_snapshot!(spec, @r#"
418 name "Usage CLI"
419 bin usage
420 flag "-f --force" global=#true
421 arg <arg1>
422 complete file run=ls descriptions=#true
423 cmd config {
424 cmd set {
425 arg <key> help="Key to set"
426 arg <value>
427 }
428 }
429 "#);
430 }
431
432 #[test]
433 #[cfg(feature = "clap")]
434 fn test_clap() {
435 let cmd = clap::Command::new("test");
436 assert_snapshot!(Spec::from(&cmd), @r#"
437 name test
438 bin test
439 usage "Usage: test"
440 "#);
441 }
442
443 macro_rules! extract_usage_tests {
444 ($($name:ident: $input:expr, $expected:expr,)*) => {
445 $(
446 #[test]
447 fn $name() {
448 let result = extract_usage_from_comments($input);
449 let expected = $expected.trim_start_matches('\n').trim_end();
450 assert_eq!(result, expected);
451 }
452 )*
453 }
454 }
455
456 extract_usage_tests! {
457 test_extract_usage_from_comments_original_hash:
458 r#"
459#!/bin/bash
460#USAGE bin "test"
461#USAGE flag "--foo" help="test"
462echo "hello"
463 "#,
464 r#"
465bin "test"
466flag "--foo" help="test"
467 "#,
468
469 test_extract_usage_from_comments_original_double_slash:
470 r#"
471#!/usr/bin/env node
472//USAGE bin "test"
473//USAGE flag "--foo" help="test"
474console.log("hello");
475 "#,
476 r#"
477bin "test"
478flag "--foo" help="test"
479 "#,
480
481 test_extract_usage_from_comments_bracket_with_space:
482 r#"
483#!/bin/bash
484# [USAGE] bin "test"
485# [USAGE] flag "--foo" help="test"
486echo "hello"
487 "#,
488 r#"
489bin "test"
490flag "--foo" help="test"
491 "#,
492
493 test_extract_usage_from_comments_bracket_no_space:
494 r#"
495#!/bin/bash
496#[USAGE] bin "test"
497#[USAGE] flag "--foo" help="test"
498echo "hello"
499 "#,
500 r#"
501bin "test"
502flag "--foo" help="test"
503 "#,
504
505 test_extract_usage_from_comments_double_slash_bracket_with_space:
506 r#"
507#!/usr/bin/env node
508// [USAGE] bin "test"
509// [USAGE] flag "--foo" help="test"
510console.log("hello");
511 "#,
512 r#"
513bin "test"
514flag "--foo" help="test"
515 "#,
516
517 test_extract_usage_from_comments_double_slash_bracket_no_space:
518 r#"
519#!/usr/bin/env node
520//[USAGE] bin "test"
521//[USAGE] flag "--foo" help="test"
522console.log("hello");
523 "#,
524 r#"
525bin "test"
526flag "--foo" help="test"
527 "#,
528
529 test_extract_usage_from_comments_stops_at_gap:
530 r#"
531#!/bin/bash
532#USAGE bin "test"
533#USAGE flag "--foo" help="test"
534
535#USAGE flag "--bar" help="should not be included"
536echo "hello"
537 "#,
538 r#"
539bin "test"
540flag "--foo" help="test"
541 "#,
542
543 test_extract_usage_from_comments_with_content_after_marker:
544 r#"
545#!/bin/bash
546# [USAGE] bin "test"
547# [USAGE] flag "--verbose" help="verbose mode"
548# [USAGE] arg "input" help="input file"
549echo "hello"
550 "#,
551 r#"
552bin "test"
553flag "--verbose" help="verbose mode"
554arg "input" help="input file"
555 "#,
556
557 test_extract_usage_from_comments_double_colon_original:
558 r#"
559::USAGE bin "test"
560::USAGE flag "--foo" help="test"
561echo "hello"
562 "#,
563 r#"
564bin "test"
565flag "--foo" help="test"
566 "#,
567
568 test_extract_usage_from_comments_double_colon_bracket_with_space:
569 r#"
570:: [USAGE] bin "test"
571:: [USAGE] flag "--foo" help="test"
572echo "hello"
573 "#,
574 r#"
575bin "test"
576flag "--foo" help="test"
577 "#,
578
579 test_extract_usage_from_comments_double_colon_bracket_no_space:
580 r#"
581::[USAGE] bin "test"
582::[USAGE] flag "--foo" help="test"
583echo "hello"
584 "#,
585 r#"
586bin "test"
587flag "--foo" help="test"
588 "#,
589
590 test_extract_usage_from_comments_double_colon_stops_at_gap:
591 r#"
592::USAGE bin "test"
593::USAGE flag "--foo" help="test"
594
595::USAGE flag "--bar" help="should not be included"
596echo "hello"
597 "#,
598 r#"
599bin "test"
600flag "--foo" help="test"
601 "#,
602
603 test_extract_usage_from_comments_double_colon_with_content_after_marker:
604 r#"
605::USAGE bin "test"
606::USAGE flag "--verbose" help="verbose mode"
607::USAGE arg "input" help="input file"
608echo "hello"
609 "#,
610 r#"
611bin "test"
612flag "--verbose" help="verbose mode"
613arg "input" help="input file"
614 "#,
615
616 test_extract_usage_from_comments_double_colon_bracket_with_space_multiple_lines:
617 r#"
618:: [USAGE] bin "myapp"
619:: [USAGE] flag "--config <file>" help="config file"
620:: [USAGE] flag "--verbose" help="verbose output"
621:: [USAGE] arg "input" help="input file"
622:: [USAGE] arg "[output]" help="output file" required=#false
623echo "done"
624 "#,
625 r#"
626bin "myapp"
627flag "--config <file>" help="config file"
628flag "--verbose" help="verbose output"
629arg "input" help="input file"
630arg "[output]" help="output file" required=#false
631 "#,
632
633 test_extract_usage_from_comments_empty:
634 r#"
635#!/bin/bash
636echo "hello"
637 "#,
638 "",
639
640 test_extract_usage_from_comments_lowercase_usage:
641 r#"
642#!/bin/bash
643#usage bin "test"
644#usage flag "--foo" help="test"
645echo "hello"
646 "#,
647 "",
648
649 test_extract_usage_from_comments_mixed_case_usage:
650 r#"
651#!/bin/bash
652#Usage bin "test"
653#Usage flag "--foo" help="test"
654echo "hello"
655 "#,
656 "",
657
658 test_extract_usage_from_comments_space_before_usage:
659 r#"
660#!/bin/bash
661# USAGE bin "test"
662# USAGE flag "--foo" help="test"
663echo "hello"
664 "#,
665 "",
666
667 test_extract_usage_from_comments_double_slash_lowercase:
668 r#"
669#!/usr/bin/env node
670//usage bin "test"
671//usage flag "--foo" help="test"
672console.log("hello");
673 "#,
674 "",
675
676 test_extract_usage_from_comments_double_slash_mixed_case:
677 r#"
678#!/usr/bin/env node
679//Usage bin "test"
680//Usage flag "--foo" help="test"
681console.log("hello");
682 "#,
683 "",
684
685 test_extract_usage_from_comments_double_slash_space_before_usage:
686 r#"
687#!/usr/bin/env node
688// USAGE bin "test"
689// USAGE flag "--foo" help="test"
690console.log("hello");
691 "#,
692 "",
693
694 test_extract_usage_from_comments_bracket_lowercase:
695 r#"
696#!/bin/bash
697#[usage] bin "test"
698#[usage] flag "--foo" help="test"
699echo "hello"
700 "#,
701 "",
702
703 test_extract_usage_from_comments_bracket_mixed_case:
704 r#"
705#!/bin/bash
706#[Usage] bin "test"
707#[Usage] flag "--foo" help="test"
708echo "hello"
709 "#,
710 "",
711
712 test_extract_usage_from_comments_bracket_space_lowercase:
713 r#"
714#!/bin/bash
715# [usage] bin "test"
716# [usage] flag "--foo" help="test"
717echo "hello"
718 "#,
719 "",
720
721 test_extract_usage_from_comments_double_colon_lowercase:
722 r#"
723::usage bin "test"
724::usage flag "--foo" help="test"
725echo "hello"
726 "#,
727 "",
728
729 test_extract_usage_from_comments_double_colon_mixed_case:
730 r#"
731::Usage bin "test"
732::Usage flag "--foo" help="test"
733echo "hello"
734 "#,
735 "",
736
737 test_extract_usage_from_comments_double_colon_space_before_usage:
738 r#"
739:: USAGE bin "test"
740:: USAGE flag "--foo" help="test"
741echo "hello"
742 "#,
743 "",
744
745 test_extract_usage_from_comments_double_colon_bracket_lowercase:
746 r#"
747::[usage] bin "test"
748::[usage] flag "--foo" help="test"
749echo "hello"
750 "#,
751 "",
752
753 test_extract_usage_from_comments_double_colon_bracket_mixed_case:
754 r#"
755::[Usage] bin "test"
756::[Usage] flag "--foo" help="test"
757echo "hello"
758 "#,
759 "",
760
761 test_extract_usage_from_comments_double_colon_bracket_space_lowercase:
762 r#"
763:: [usage] bin "test"
764:: [usage] flag "--foo" help="test"
765echo "hello"
766 "#,
767 "",
768 }
769}