Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 88 additions & 5 deletions argv/src/spec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2787,13 +2787,23 @@ fn prop(out: &mut String, key: &str, value: &str) -> core::fmt::Result {
/// Render a string value in KDL's canonical form.
///
/// KDL 2 writes a string as a plain identifier whenever that cannot be confused
/// with a number or keyword, and quotes it otherwise. Keeping this dependency-free
/// copy of the identifier predicate makes derive output byte-for-byte stable across
/// a usage-lib parse/serialize round trip.
/// with a number or keyword, and quotes it otherwise. Newlines use the same raw
/// multiline form as usage-lib's `string_entry` (`#"""…"""#`), so a wrapped help
/// paragraph is not one giant escaped line in generated `.usage.kdl`. Control
/// characters other than newline and tab still force a quoted escape, matching
/// that helper. Keeping this dependency-free copy of the identifier predicate
/// makes derive output byte-for-byte stable across a usage-lib parse/serialize
/// round trip.
fn quoted(value: &str) -> String {
if !value.is_empty() && is_plain_kdl_identifier(value) {
return value.to_owned();
}
let has_unsafe_control = value
.chars()
.any(|c| c.is_control() && c != '\n' && c != '\t');
if value.contains('\n') && !has_unsafe_control {
return raw_multiline_kdl_string(value);
}
let mut out = String::with_capacity(value.len() + 2);
out.push('"');
for ch in value.chars() {
Expand All @@ -2816,6 +2826,27 @@ fn quoted(value: &str) -> String {
out
}

/// Number of `#` delimiters so the closer cannot appear inside `value`.
///
/// Twin of `usage_lib::spec::helpers::raw_multiline_hash_count`: `n` hashes
/// whenever a line contains `"""` followed by `n-1` hashes.
fn raw_multiline_hash_count(value: &str) -> usize {
let mut max_count = 0;
for line in value.lines() {
for (idx, _) in line.match_indices("\"\"\"") {
let after = &line[idx + 3..];
let count = after.chars().take_while(|&c| c == '#').count();
max_count = max_count.max(count);
}
}
max_count + 1
Comment thread
cursor[bot] marked this conversation as resolved.
}

fn raw_multiline_kdl_string(value: &str) -> String {
let hashes = "#".repeat(raw_multiline_hash_count(value));
format!("{hashes}\"\"\"\n{value}\n\"\"\"{hashes}")
}

fn is_plain_kdl_identifier(value: &str) -> bool {
value.chars().all(|c| !is_disallowed_kdl_identifier_char(c))
&& !starts_like_kdl_number(value)
Expand Down Expand Up @@ -3739,7 +3770,59 @@ mod tests {
assert_eq!(quoted("with space"), r#""with space""#);
assert_eq!(quoted(r#"say "hi""#), r#""say \"hi\"""#);
assert_eq!(quoted("a\\b"), r#""a\\b""#);
assert_eq!(quoted("one\ntwo"), r#""one\ntwo""#);
assert_eq!(
quoted("one\ntwo"),
"#\"\"\"\none\ntwo\n\"\"\"#",
"newlines use KDL raw multiline syntax, not escaped quoted strings"
);
assert_eq!(
quoted("before\n\"\"\"#\nafter"),
"##\"\"\"\nbefore\n\"\"\"#\nafter\n\"\"\"##",
"enough hashes that the payload's closer cannot terminate the string"
);
assert_eq!(
quoted("before\n\"\"\"##\nafter"),
"###\"\"\"\nbefore\n\"\"\"##\nafter\n\"\"\"###"
);
assert_eq!(
quoted("ansi\n\u{1b}[0m"),
"\"ansi\\n\\u{1b}[0m\"",
"unsafe control characters keep the quoted escape form"
);
assert_eq!(quoted("tab\tonly"), "\"tab\\tonly\"");
}

#[test]
fn multiline_help_round_trips_through_emitted_kdl() {
static ROOT: Command = Command {
name: "ex",
..Command::EMPTY
};
static META: CommandMeta = CommandMeta {
cmd: &ROOT,
about: Some("short"),
long_about: Some("First paragraph.\n\n eval \"$(tool activate)\""),
..CommandMeta::EMPTY
};
static SPEC: Spec = Spec {
name: "ex",
bin: Some("ex"),
root: &META,
..Spec::EMPTY
};
let kdl = SPEC.to_kdl();
assert!(
kdl.contains("#\"\"\""),
"expected raw multiline syntax:\n{kdl}"
);
assert!(
!kdl.contains("\\n"),
"newlines must not be escaped in the generated document:\n{kdl}"
);
assert!(
kdl.contains(" eval \"$(tool activate)\""),
"indented example must stay indented:\n{kdl}"
);
}

fn test_completer(_: &CompleteCtx<'_>) -> Vec<Candidate<'static>> {
Expand Down Expand Up @@ -4100,7 +4183,7 @@ mod tests {
assert!(base.contains("name ex"), "{base}");
assert!(base.contains("version \"1.0.0\""), "{base}");
assert!(
base.contains("long_version \"1.0.0\\ncommit old\""),
base.contains("#\"\"\"\n1.0.0\ncommit old\n\"\"\"#"),
"{base}"
);
assert!(!base.contains("effect="), "{base}");
Expand Down
88 changes: 82 additions & 6 deletions conformance/tests/metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -353,14 +353,13 @@ fn doc_comments_can_preserve_their_layout() {
#[test]
fn ordinary_multiline_doc_attributes_keep_their_indentation() {
let spec: LibSpec = OrdinaryComments::to_kdl().parse().expect("valid spec");
// The continuation is indented, so it is an example line rather than wrapped
// prose: both forms keep the newline instead of turning it into spaces.
assert_eq!(
spec.about.as_deref(),
Some("Ordinary first line indented continuation")
);
assert_eq!(
spec.about_long.as_deref(),
Some("Ordinary first line\n indented continuation")
);
assert!(spec.about_long.is_none());
}

#[test]
Expand All @@ -375,6 +374,83 @@ fn verbatim_multiline_doc_attributes_keep_their_indentation() {
assert!(spec.about_long.is_none());
}

/// Generates shell code that enables automatic daemon management when changing
/// directories. Required for auto-start/stop features in pitchfork.toml.
///
/// Add to your shell config:
///
/// eval "$(pitchfork activate bash)"
#[derive(Cli)]
#[usage(bin = "flowed-comments")]
struct FlowedComments {
/// Number of jobs to run.
///
/// More detail continues
/// onto the next source line.
#[usage(long)]
jobs: bool,
}

#[test]
fn ordinary_prose_doc_comments_flow_like_clap() {
let spec: LibSpec = FlowedComments::to_kdl().parse().expect("valid spec");
assert_eq!(
spec.about.as_deref(),
Some(
"Generates shell code that enables automatic daemon management when changing directories. Required for auto-start/stop features in pitchfork.toml."
)
);
let long = spec.about_long.as_deref().expect("long help");
assert_eq!(
long,
"Generates shell code that enables automatic daemon management when changing directories. Required for auto-start/stop features in pitchfork.toml.\n\nAdd to your shell config:\n\n eval \"$(pitchfork activate bash)\""
);
assert!(
spec.about.as_deref().unwrap().ends_with('.'),
"trailing periods must survive"
);
assert!(
!long.contains("changing\ndirectories"),
"source wraps must not become markdown line breaks:\n{long}"
);

let jobs = spec.cmd.flags.iter().find(|f| f.name == "jobs").unwrap();
assert_eq!(jobs.help.as_deref(), Some("Number of jobs to run."));
assert_eq!(
jobs.help_long.as_deref(),
Some("Number of jobs to run.\n\nMore detail continues onto the next source line.")
);

let parsed = FlowedComments::parse_from(&[std::ffi::OsStr::new("--jobs")])
.expect("the metadata still parses");
assert!(parsed.jobs);
}

/// Short summary.
///
/// ~~~
/// kept
/// as
/// lines
/// ~~~
#[derive(Cli)]
#[usage(bin = "tilde-fenced-comments")]
struct TildeFencedComments {}

#[test]
fn ordinary_tilde_fenced_blocks_keep_their_line_breaks() {
let spec: LibSpec = TildeFencedComments::to_kdl().parse().expect("valid spec");
assert_eq!(spec.about.as_deref(), Some("Short summary."));
assert!(
spec.about.as_deref().unwrap().ends_with('.'),
"trailing periods must survive"
);
assert_eq!(
spec.about_long.as_deref(),
Some("Short summary.\n\n~~~\nkept\nas\nlines\n~~~")
);
}

#[test]
fn help_text_can_keep_line_breaks_a_comment_would_flow() {
// A doc comment's first paragraph is read the way Rust reads one, so a line break inside
Expand Down Expand Up @@ -722,8 +798,8 @@ fn the_source_code_link_template_survives_the_typed_spec() {
let kdl = Linked::to_kdl();
let spec: LibSpec = kdl.parse().expect("valid spec");

// Newlines make it through: the writer escapes them into one quoted string rather than
// emitting a `#"""` block, and the template means nothing if its lines run together.
// Newlines make it through as a KDL raw multiline string, and the template means
// nothing if its lines run together.
let template = spec
.source_code_link_template
.as_deref()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,40 @@ expression: SPEC.to_kdl()
name ex
bin ex
version "1.2.3"
long_version "1.2.3\ncommit abc123"
source_code_link_template "{%- if cmd.subcommands | length > 0 -%}\n{%- set path = path ~ \"/mod.rs\" -%}\n{%- endif -%}\nhttps://example.com/blob/main/src/{{path}}"
long_version #"""
1.2.3
commit abc123
"""#
source_code_link_template #"""
{%- if cmd.subcommands | length > 0 -%}
{%- set path = path ~ "/mod.rs" -%}
{%- endif -%}
https://example.com/blob/main/src/{{path}}
"""#
about "does things"
long_about "Does things, at length."
usage "Usage: ex <COMMAND>\n ex --version \"quoted\""
help_template "{{about}}\n\n{{usage}}\n\n{{flags}}\n\n{{args}}\n\n{{commands}}"
usage #"""
Usage: ex <COMMAND>
ex --version "quoted"
"""#
help_template #"""
{{about}}

{{usage}}

{{flags}}

{{args}}

{{commands}}
"""#
default_subcommand run
example "ex a.txt" header=Basic help="the simplest thing"
flag "-j --jobs" help="how many jobs, and a quote: \"" global=#true help_heading=Performance env=EX_JOBS default="4" {
long_help "More about jobs.\nOn two lines."
long_help #"""
More about jobs.
On two lines.
"""#
arg <n>
}
flag --color help="colorize output" negate=--no-color default="true"
Expand Down Expand Up @@ -45,7 +69,11 @@ arg "[file]" help="the file" help_heading=Input env=EX_FILE default=a.txt
cmd install help="install a tool" effect=write {
alias i
alias add hide=#true
long_help "Installs a tool.\n\nTakes a while."
long_help #"""
Installs a tool.

Takes a while.
"""#
example "ex install node@20" help="install a specific version"
flag "-f --force" help="overwrite an existing install"
arg <tool> help="the tool to install"
Expand Down
21 changes: 19 additions & 2 deletions conformance/tests/spec_roundtrip.rs
Original file line number Diff line number Diff line change
Expand Up @@ -326,8 +326,8 @@ static SPEC: Spec = Spec {
author: None,
license: None,
repository: None,
// Multi-line and full of quotes, because that is what a real one looks like and the
// writer has to escape it into a single KDL string rather than a `#"""` block.
// Multi-line and full of quotes, because that is what a real one looks like.
// Newlines must come out as KDL raw multiline strings, not one escaped line.
source_code_link_template: Some(concat!(
"{%- if cmd.subcommands | length > 0 -%}\n",
"{%- set path = path ~ \"/mod.rs\" -%}\n",
Expand Down Expand Up @@ -355,6 +355,23 @@ fn parsed() -> LibSpec {
.unwrap_or_else(|e| panic!("usage-lib could not parse the emitted spec: {e}\n\n{kdl}"))
}

#[test]
fn multiline_strings_use_raw_kdl_syntax() {
let kdl = SPEC.to_kdl();
assert!(
kdl.contains("#\"\"\""),
"newline-bearing values should use raw multiline syntax:\n{kdl}"
);
assert!(
!kdl.contains(r#"long_help "More about jobs.\nOn two lines.""#),
"prose newlines must not be escaped into one line:\n{kdl}"
);
assert!(
kdl.contains(r#"long_help "Deletes things.\u{1b}[0m Carefully.""#),
"unsafe control characters still force a quoted escape:\n{kdl}"
);
}

#[test]
fn the_program_itself_survives() {
let spec = parsed();
Expand Down
Loading
Loading