From f47fe0d265617566aaa262189c83970e6c12a8ce Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:09:00 +0000 Subject: [PATCH 1/2] feat(spec): parse usage comments from strings --- lib/src/spec/mod.rs | 43 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/lib/src/spec/mod.rs b/lib/src/spec/mod.rs index 15b896b5..c5d2d5d9 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,22 @@ 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. + #[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) @@ -982,4 +997,22 @@ 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"]); + } } From 4cc4e7421706496e12840b066727a76ac5506a0f Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:16:12 +0000 Subject: [PATCH 2/2] fix(spec): reject relative string includes --- lib/src/spec/mod.rs | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/lib/src/spec/mod.rs b/lib/src/spec/mod.rs index c5d2d5d9..5970128e 100644 --- a/lib/src/spec/mod.rs +++ b/lib/src/spec/mod.rs @@ -130,7 +130,9 @@ impl Spec { /// /// 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. + /// 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("")) @@ -239,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(), @@ -1015,4 +1019,17 @@ echo "hello" 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:?}"), + } + } }