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