mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-31 13:56:54 -06:00
Merge pull request #1615 from avhn/fix/1396-cli-completions-man-pages
M0.9: generate shell completions and man pages for figcraft-cli (part of #1396)
This commit is contained in:
Generated
+27
@@ -768,6 +768,15 @@ dependencies = [
|
||||
"strsim",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_complete"
|
||||
version = "4.5.67"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1248e420506b523e97ebd7b610656114abadbe73b04279a86768a12ce1860837"
|
||||
dependencies = [
|
||||
"clap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.6.7"
|
||||
@@ -786,6 +795,16 @@ version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
|
||||
|
||||
[[package]]
|
||||
name = "clap_mangen"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "211d617eaa4b735c96c9e0228fcbdb5120ef623f2b8cb67ffb84c3e02dbc28a4"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"roff",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clipboard-win"
|
||||
version = "5.4.3"
|
||||
@@ -1779,6 +1798,8 @@ name = "figcraft-cli"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"clap_complete",
|
||||
"clap_mangen",
|
||||
"figcraft-doc",
|
||||
"figcraft-engine",
|
||||
"figcraft-format",
|
||||
@@ -4468,6 +4489,12 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "roff"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189"
|
||||
|
||||
[[package]]
|
||||
name = "roxmltree"
|
||||
version = "0.20.0"
|
||||
|
||||
+2
-1
File diff suppressed because one or more lines are too long
@@ -24,3 +24,8 @@ figcraft-mcp = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
clap = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
# 4.6 breaks Bash subcommand completion for hyphenated binary names (clap-rs/clap#6421).
|
||||
clap_complete = "~4.5.67"
|
||||
clap_mangen = "0.3"
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
//! Writes figcraft-cli's shell completions and recursive man pages into a directory:
|
||||
//!
|
||||
//! ```sh
|
||||
//! cargo run -p figcraft-cli --example man-and-completions -- <out-dir>
|
||||
//! ```
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
#[path = "../src/args.rs"]
|
||||
#[expect(dead_code, reason = "Generation uses clap's command metadata, not the parsed fields or runtime parser.")]
|
||||
mod args;
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use clap::{CommandFactory, ValueEnum};
|
||||
use clap_complete::Shell;
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let mut args = std::env::args_os().skip(1);
|
||||
let (Some(dir), None) = (args.next(), args.next()) else {
|
||||
eprintln!("usage: man-and-completions <out-dir>");
|
||||
return ExitCode::from(2);
|
||||
};
|
||||
let dir = Path::new(&dir);
|
||||
match generate(dir) {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
eprintln!("error: {}: {e}", dir.display());
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn generate(dir: &Path) -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let mut command = args::Cli::command();
|
||||
let name = command.get_name().to_owned();
|
||||
for &shell in Shell::value_variants() {
|
||||
clap_complete::generate_to(shell, &mut command, &name, dir)?;
|
||||
}
|
||||
// Completions build the command and add synthetic help subcommands. A fresh command keeps
|
||||
// those out of the man pages (such as figcraft-cli-help-export.1).
|
||||
clap_mangen::generate_to(args::Cli::command(), dir)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
//! Generated CLI documentation uses the parser's command definition, without running commands.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
#[path = "../../examples/man-and-completions.rs"]
|
||||
#[expect(dead_code, reason = "These tests call generate directly; the example's main is smoke-tested separately.")]
|
||||
mod generator;
|
||||
|
||||
struct TempDir(PathBuf);
|
||||
|
||||
impl TempDir {
|
||||
fn path(&self) -> &Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn tmp(name: &str) -> std::io::Result<TempDir> {
|
||||
static NEXT: AtomicUsize = AtomicUsize::new(0);
|
||||
loop {
|
||||
let n = NEXT.fetch_add(1, Ordering::Relaxed);
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-cli-test-{}-{n}-{name}", std::process::id()));
|
||||
match fs::create_dir(&dir) {
|
||||
Ok(()) => return Ok(TempDir(dir)),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completions_name_the_cli_its_subcommands_and_options() -> std::io::Result<()> {
|
||||
let temp = tmp("completions")?;
|
||||
let dir = temp.path();
|
||||
generator::generate(dir)?;
|
||||
for name in ["figcraft-cli.bash", "figcraft-cli.elv", "figcraft-cli.fish", "_figcraft-cli.ps1", "_figcraft-cli"] {
|
||||
let text = fs::read_to_string(dir.join(name))?;
|
||||
for word in [
|
||||
"figcraft-cli",
|
||||
"info",
|
||||
"export",
|
||||
"convert",
|
||||
"render",
|
||||
"run",
|
||||
"commands",
|
||||
"mcp",
|
||||
"plugin",
|
||||
"perf",
|
||||
"publish",
|
||||
"no-system-fonts",
|
||||
"format",
|
||||
"automation-read-root",
|
||||
"server",
|
||||
] {
|
||||
assert!(text.contains(word), "{name} does not contain {word}");
|
||||
}
|
||||
assert!(!text.contains("man-and-completions"), "{name} names the example instead of the CLI");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bash_dispatch_states_have_matching_option_blocks() -> std::io::Result<()> {
|
||||
let temp = tmp("bash-completion-states")?;
|
||||
let dir = temp.path();
|
||||
generator::generate(dir)?;
|
||||
let text = fs::read_to_string(dir.join("figcraft-cli.bash"))?;
|
||||
let states: Vec<_> =
|
||||
text.lines().filter_map(|line| line.trim().strip_prefix("cmd=\"")?.strip_suffix('"')).filter(|state| !state.is_empty()).collect();
|
||||
assert!(states.len() > 1, "the completion has no subcommand dispatch states");
|
||||
for state in states {
|
||||
let block = format!("{state})");
|
||||
assert!(text.lines().any(|line| line.trim() == block), "Bash dispatch state {state} has no option block");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn man_pages_cover_every_subcommand_without_synthetic_help_pages() -> std::io::Result<()> {
|
||||
let temp = tmp("man-pages")?;
|
||||
let dir = temp.path();
|
||||
generator::generate(dir)?;
|
||||
let mut pages = Vec::new();
|
||||
for entry in fs::read_dir(dir)? {
|
||||
let path = entry?.path();
|
||||
if path.extension().is_some_and(|ext| ext == "1") {
|
||||
pages.push(path.file_name().unwrap_or_default().to_string_lossy().into_owned());
|
||||
}
|
||||
}
|
||||
pages.sort();
|
||||
assert_eq!(
|
||||
pages,
|
||||
[
|
||||
"figcraft-cli-commands.1",
|
||||
"figcraft-cli-convert.1",
|
||||
"figcraft-cli-export.1",
|
||||
"figcraft-cli-info.1",
|
||||
"figcraft-cli-mcp.1",
|
||||
"figcraft-cli-perf.1",
|
||||
"figcraft-cli-plugin-publish.1",
|
||||
"figcraft-cli-plugin.1",
|
||||
"figcraft-cli-render.1",
|
||||
"figcraft-cli-run.1",
|
||||
"figcraft-cli.1",
|
||||
]
|
||||
);
|
||||
for name in &pages {
|
||||
let text = fs::read_to_string(dir.join(name))?.replace("\\-", "-");
|
||||
assert!(text.contains(".SH NAME"), "{name} has no NAME section");
|
||||
assert!(text.contains(name.trim_end_matches(".1")), "{name} has the wrong command name");
|
||||
assert!(!text.contains("man-and-completions"), "{name} names the example instead of the CLI");
|
||||
}
|
||||
for (page, option) in [
|
||||
("figcraft-cli.1", "--no-system-fonts"),
|
||||
("figcraft-cli-export.1", "--format"),
|
||||
("figcraft-cli-export.1", "--automation-read-root"),
|
||||
("figcraft-cli-plugin-publish.1", "--server"),
|
||||
] {
|
||||
let text = fs::read_to_string(dir.join(page))?.replace("\\-", "-");
|
||||
assert!(text.contains(option), "{page} does not contain {option}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generation_creates_the_output_directory_and_can_run_again() -> std::io::Result<()> {
|
||||
let temp = tmp("generated-docs-repeat")?;
|
||||
let dir = temp.path();
|
||||
let output = dir.join("new").join("docs");
|
||||
assert!(!output.exists());
|
||||
generator::generate(&output)?;
|
||||
let page = output.join("figcraft-cli.1");
|
||||
let first = fs::read(&page)?;
|
||||
assert!(!first.is_empty());
|
||||
fs::write(&page, "old page")?;
|
||||
generator::generate(&output)?;
|
||||
assert_eq!(fs::read(page)?, first);
|
||||
assert_eq!(fs::read_dir(output)?.count(), 16);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generation_returns_filesystem_errors() -> std::io::Result<()> {
|
||||
let temp = tmp("generated-docs-errors")?;
|
||||
let dir = temp.path();
|
||||
let file = dir.join("not-a-directory");
|
||||
fs::write(&file, "keep me")?;
|
||||
assert!(generator::generate(&file).is_err());
|
||||
assert_eq!(fs::read_to_string(&file)?, "keep me");
|
||||
// A valid directory can still fail when writing one of the generated pages.
|
||||
fs::create_dir(dir.join("figcraft-cli.1"))?;
|
||||
assert!(generator::generate(dir).is_err());
|
||||
Ok(())
|
||||
}
|
||||
@@ -6,6 +6,7 @@ mod cli;
|
||||
mod export_depth;
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod fonts;
|
||||
mod generated_docs;
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod pdf;
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
+30
-1
@@ -29,7 +29,36 @@ Not yet as RFC-0012 asks:
|
||||
function the tests call in-process.
|
||||
- `FIGCRAFT_NO_SYSTEM_FONTS` and `FIGCRAFT_SERVER_KEY` are read where they're used, not declared
|
||||
on a flag, so `--help` names them only in the flag descriptions.
|
||||
- There are no generated shell completions or man pages, and no ast-grep checks in CI yet.
|
||||
- Packages don't install the generated shell completions or man pages yet.
|
||||
- There are no ast-grep checks in CI yet.
|
||||
|
||||
## Generating shell completions and man pages
|
||||
|
||||
From the workspace root, run:
|
||||
|
||||
```sh
|
||||
cargo run -p figcraft-cli --example man-and-completions -- target/cli-docs
|
||||
```
|
||||
|
||||
The example reuses the CLI's clap definitions without running a command, scanning fonts or opening
|
||||
a window. It creates the output directory if needed and overwrites its generated files on a
|
||||
second run. The output directory is its only argument. A missing or extra argument is a usage
|
||||
error (exit 2); a filesystem error exits with 1.
|
||||
|
||||
The directory contains completions for five shells:
|
||||
|
||||
| Shell | File |
|
||||
|---|---|
|
||||
| Bash | `figcraft-cli.bash` |
|
||||
| Elvish | `figcraft-cli.elv` |
|
||||
| Fish | `figcraft-cli.fish` |
|
||||
| PowerShell | `_figcraft-cli.ps1` |
|
||||
| Zsh | `_figcraft-cli` |
|
||||
|
||||
It also contains `figcraft-cli.1` and one man page per subcommand, including nested commands such
|
||||
as `figcraft-cli-plugin-publish.1`. For example, `man target/cli-docs/figcraft-cli-export.1` shows
|
||||
the export subcommand's options. Generating these files doesn't install them; package installation
|
||||
is still outstanding in [#1396](https://github.com/storytold/figcraft/issues/1396).
|
||||
|
||||
## Fonts
|
||||
|
||||
|
||||
+3
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user