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 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}