diff --git a/lib/src/spec/mod.rs b/lib/src/spec/mod.rs index 15b896b5..5970128e 100644 --- a/lib/src/spec/mod.rs +++ b/lib/src/spec/mod.rs @@ -85,7 +85,7 @@ impl Spec { /// /// Automatically detects whether the file is: /// - A `.kdl` or `.usage.kdl` file containing a raw spec - /// - A script file with embedded `# USAGE:` comments + /// - A script file with embedded `#USAGE` comments /// /// If `bin` is not specified in the spec, it defaults to the filename. #[must_use = "parsing result should be used"] @@ -107,13 +107,12 @@ impl Spec { } /// Parse a spec from a script file's embedded USAGE comments. /// - /// Extracts the spec from comment lines starting with `# USAGE:` or `// USAGE:`. + /// Extracts the spec from comment lines marked with `#USAGE`, `//USAGE`, + /// `::USAGE`, or their `[USAGE]` variants. /// If `bin` is not specified in the spec, it defaults to the filename. #[must_use = "parsing result should be used"] pub fn parse_script(file: &Path) -> Result { - let raw = extract_usage_from_comments(&file::read_to_string(file)?); - let ctx = ParsingContext::new(file, &raw); - let mut spec = Self::parse(&ctx, &raw)?; + let mut spec = Self::parse_script_with_path(&file::read_to_string(file)?, file)?; if spec.bin.is_empty() { spec.bin = file .file_name() @@ -127,6 +126,24 @@ impl Spec { Ok(spec) } + /// Parse a spec from a script string's embedded USAGE comments. + /// + /// Extracts the spec from comment lines marked with `#USAGE`, `//USAGE`, + /// `::USAGE`, or their `[USAGE]` variants. Unlike [`Self::parse_script`], + /// this function cannot infer `bin` or `name` from a filename. Relative + /// `include` paths are rejected because there is no source path to resolve + /// them against; absolute `include` paths remain supported. + #[must_use = "parsing result should be used"] + pub fn parse_script_str(input: &str) -> Result { + Self::parse_script_with_path(input, Path::new("")) + } + + fn parse_script_with_path(input: &str, file: &Path) -> Result { + let raw = extract_usage_from_comments(input); + let ctx = ParsingContext::new(file, &raw); + Self::parse(&ctx, &raw) + } + #[deprecated] pub fn parse_spec(input: &str) -> Result { Self::parse(&Default::default(), input) @@ -224,10 +241,12 @@ impl Spec { .file .parent() .ok_or_else(|| { - ctx.build_err( - format!("cannot get parent of {}", ctx.file.display()), - node.span(), - ) + let msg = if ctx.file.as_os_str().is_empty() { + "relative includes require a source file".to_string() + } else { + format!("cannot get parent of {}", ctx.file.display()) + }; + ctx.build_err(msg, node.span()) })? .join(file), false => file.to_path_buf(), @@ -982,4 +1001,35 @@ example "demo --version" ); assert!(output.contains("example \"demo --version\"")); } + + #[test] + fn test_parse_script_str() { + let spec = Spec::parse_script_str( + r#" +#!/bin/bash +#USAGE bin "test" +#USAGE flag "--foo" help="test" +echo "hello" + "#, + ) + .unwrap(); + + assert_eq!(spec.bin, "test"); + assert_eq!(spec.name, "test"); + assert_eq!(spec.cmd.flags.len(), 1); + assert_eq!(spec.cmd.flags[0].long, ["foo"]); + } + + #[test] + fn test_parse_script_str_rejects_relative_includes() { + let err = Spec::parse_script_str(r#"#USAGE include file="relative.usage.kdl""#) + .expect_err("relative includes need a source path"); + + match err { + UsageErr::InvalidInput(msg, _, _) => { + assert_eq!(msg, "relative includes require a source file"); + } + err => panic!("unexpected error: {err:?}"), + } + } }