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        && full
229            .lines()
230            .any(|l| l.starts_with("#USAGE") || l.starts_with("//USAGE"))
231    {
232        return Ok((extract_usage_from_comments(&full), full));
233    }
234    let schema = full.strip_prefix("#!/usr/bin/env usage\n").unwrap_or(&full);
235    let (schema, body) = schema.split_once("\n#!").unwrap_or((schema, ""));
236    let schema = schema
237        .trim()
238        .lines()
239        .filter(|l| !l.starts_with('#'))
240        .collect::<Vec<_>>()
241        .join("\n");
242    let body = format!("#!{body}");
243    Ok((schema, body))
244}
245
246fn extract_usage_from_comments(full: &str) -> String {
247    let mut usage = vec![];
248    let mut found = false;
249    for line in full.lines() {
250        if line.starts_with("#USAGE") || line.starts_with("//USAGE") {
251            found = true;
252            let line = line
253                .strip_prefix("#USAGE")
254                .unwrap_or_else(|| line.strip_prefix("//USAGE").unwrap());
255            usage.push(line.trim());
256        } else if found {
257            // if there is a gap, stop reading
258            break;
259        }
260    }
261    usage.join("\n")
262}
263
264fn set_subcommand_ancestors(cmd: &mut SpecCommand, ancestors: &[String]) {
265    let ancestors = ancestors.to_vec();
266    for subcmd in cmd.subcommands.values_mut() {
267        subcmd.full_cmd = ancestors
268            .clone()
269            .into_iter()
270            .chain(once(subcmd.name.clone()))
271            .collect();
272        set_subcommand_ancestors(subcmd, &subcmd.full_cmd.clone());
273    }
274    if cmd.usage.is_empty() {
275        cmd.usage = cmd.usage();
276    }
277}
278
279impl Display for Spec {
280    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
281        let mut doc = KdlDocument::new();
282        let nodes = &mut doc.nodes_mut();
283        if !self.name.is_empty() {
284            let mut node = KdlNode::new("name");
285            node.push(KdlEntry::new(self.name.clone()));
286            nodes.push(node);
287        }
288        if !self.bin.is_empty() {
289            let mut node = KdlNode::new("bin");
290            node.push(KdlEntry::new(self.bin.clone()));
291            nodes.push(node);
292        }
293        if let Some(version) = &self.version {
294            let mut node = KdlNode::new("version");
295            node.push(KdlEntry::new(version.clone()));
296            nodes.push(node);
297        }
298        if let Some(author) = &self.author {
299            let mut node = KdlNode::new("author");
300            node.push(KdlEntry::new(author.clone()));
301            nodes.push(node);
302        }
303        if let Some(about) = &self.about {
304            let mut node = KdlNode::new("about");
305            node.push(KdlEntry::new(about.clone()));
306            nodes.push(node);
307        }
308        if let Some(source_code_link_template) = &self.source_code_link_template {
309            let mut node = KdlNode::new("source_code_link_template");
310            node.push(KdlEntry::new(source_code_link_template.clone()));
311            nodes.push(node);
312        }
313        if let Some(about_md) = &self.about_md {
314            let mut node = KdlNode::new("about_md");
315            node.push(KdlEntry::new(KdlValue::String(about_md.clone())));
316            nodes.push(node);
317        }
318        if let Some(long_about) = &self.about_long {
319            let mut node = KdlNode::new("long_about");
320            node.push(KdlEntry::new(KdlValue::String(long_about.clone())));
321            nodes.push(node);
322        }
323        if let Some(disable_help) = self.disable_help {
324            let mut node = KdlNode::new("disable_help");
325            node.push(KdlEntry::new(disable_help));
326            nodes.push(node);
327        }
328        if let Some(min_usage_version) = &self.min_usage_version {
329            let mut node = KdlNode::new("min_usage_version");
330            node.push(KdlEntry::new(min_usage_version.clone()));
331            nodes.push(node);
332        }
333        if !self.usage.is_empty() {
334            let mut node = KdlNode::new("usage");
335            node.push(KdlEntry::new(self.usage.clone()));
336            nodes.push(node);
337        }
338        for flag in self.cmd.flags.iter() {
339            nodes.push(flag.into());
340        }
341        for arg in self.cmd.args.iter() {
342            nodes.push(arg.into());
343        }
344        for complete in self.complete.values() {
345            nodes.push(complete.into());
346        }
347        for complete in self.cmd.complete.values() {
348            nodes.push(complete.into());
349        }
350        for cmd in self.cmd.subcommands.values() {
351            nodes.push(cmd.into())
352        }
353        if !self.config.is_empty() {
354            nodes.push((&self.config).into());
355        }
356        doc.autoformat_config(&kdl::FormatConfigBuilder::new().build());
357        write!(f, "{doc}")
358    }
359}
360
361impl FromStr for Spec {
362    type Err = UsageErr;
363
364    fn from_str(s: &str) -> Result<Self, Self::Err> {
365        Self::parse(&Default::default(), s)
366    }
367}
368
369#[cfg(feature = "clap")]
370impl From<&clap::Command> for Spec {
371    fn from(cmd: &clap::Command) -> Self {
372        Spec {
373            name: cmd.get_name().to_string(),
374            bin: cmd.get_bin_name().unwrap_or(cmd.get_name()).to_string(),
375            cmd: cmd.into(),
376            version: cmd.get_version().map(|v| v.to_string()),
377            about: cmd.get_about().map(|a| a.to_string()),
378            about_long: cmd.get_long_about().map(|a| a.to_string()),
379            usage: cmd.clone().render_usage().to_string(),
380            ..Default::default()
381        }
382    }
383}
384
385#[inline]
386pub fn is_true(b: &bool) -> bool {
387    *b
388}
389
390#[inline]
391pub fn is_false(b: &bool) -> bool {
392    !is_true(b)
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398    use insta::assert_snapshot;
399
400    #[test]
401    fn test_display() {
402        let spec = Spec::parse(
403            &Default::default(),
404            r#"
405name "Usage CLI"
406bin "usage"
407arg "arg1"
408flag "-f --force" global=#true
409cmd "config" {
410  cmd "set" {
411    arg "key" help="Key to set"
412    arg "value"
413  }
414}
415complete "file" run="ls"
416        "#,
417        )
418        .unwrap();
419        assert_snapshot!(spec, @r#"
420        name "Usage CLI"
421        bin usage
422        flag "-f --force" global=#true
423        arg <arg1>
424        complete file run=ls
425        cmd config {
426            cmd set {
427                arg <key> help="Key to set"
428                arg <value>
429            }
430        }
431        "#);
432    }
433
434    #[test]
435    #[cfg(feature = "clap")]
436    fn test_clap() {
437        let cmd = clap::Command::new("test");
438        assert_snapshot!(Spec::from(&cmd), @r#"
439        name test
440        bin test
441        usage "Usage: test"
442        "#);
443    }
444}