usage/spec/
mod.rs

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            // if there is a gap, stop reading
256            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}