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:
BFlat
2026-10-30 15:09:34 +07:00
committed by GitHub
8 changed files with 273 additions and 3 deletions
Generated
+27
View File
@@ -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
View File
File diff suppressed because one or more lines are too long
+5
View File
@@ -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(())
}
+1
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because one or more lines are too long