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