Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
115 changes: 115 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

126 changes: 109 additions & 17 deletions crates/forge-docs-check/src/checks/cli_commands.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
//! Rule `cli-command`: every `forge` subcommand is documented in `crates/forge.md`.
//!
//! Caught the missing `forge smelt` entry. The command list is sourced from the
//! CLI's own usage banner in `forge_cli/src/main.rs` (a single in-repo source),
//! so adding a subcommand there surfaces the requirement automatically. After the
//! Phase 5 clap migration this source switches to the clap command model.
//! CLI's own clap command model in `forge_cli/src/main.rs` (a single in-repo
//! source of truth), so adding a subcommand there surfaces the documentation
//! requirement automatically.
//!
//! Before the Phase 5 clap migration this was sourced from a hand-written
//! `forge <a|b|c>` usage banner; clap derive replaced that banner with the
//! `#[derive(Subcommand)] enum Commands { .. }` block, so this rule now parses
//! the enum variants and applies clap's default PascalCase -> kebab-case rename
//! to recover the exact subcommand names the CLI exposes.

use crate::checks::read_optional;
use crate::discovery::Workspace;
use crate::Finding;
use regex::Regex;

pub fn check(ws: &Workspace) -> Vec<Finding> {
let main_rs = ws.root.join("crates/forge_cli/src/main.rs");
Expand All @@ -30,7 +35,7 @@ pub fn check(ws: &Workspace) -> Vec<Finding> {
_ => {
return vec![Finding::new(
"cli-command",
"could not find the `forge <a|b|c>` usage banner in forge_cli/src/main.rs to source the command list",
"could not find the `#[derive(Subcommand)] enum Commands` block in forge_cli/src/main.rs to source the command list",
)]
}
};
Expand Down Expand Up @@ -61,17 +66,104 @@ pub fn check(ws: &Workspace) -> Vec<Finding> {
findings
}

/// Extract the pipe-separated command list from the first `forge <a|b|c>` usage
/// banner found in the CLI source.
/// Extract the top-level variant names from the clap `enum Commands { .. }`
/// block and convert each to the kebab-case name clap exposes on the CLI.
fn parse_subcommands(src: &str) -> Option<Vec<String>> {
let re = Regex::new(r"forge <([a-z][a-z|]+)>").expect("valid usage regex");
let caps = re.captures(src)?;
let list = caps
.get(1)?
.as_str()
.split('|')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect::<Vec<_>>();
Some(list)
let body = enum_body(src, "Commands")?;

let mut variants = Vec::new();
let mut depth = 0i32;
for line in body.lines() {
let trimmed = line.trim();
// A variant name is a PascalCase identifier sitting at the enum's top
// level (depth 0). Struct-variant fields live at depth >= 1, and
// attributes/doc-comments (`#[..]`, `///`) don't start with an
// uppercase identifier, so both are skipped naturally.
if depth == 0 {
if let Some(name) = variant_name(trimmed) {
variants.push(to_kebab(&name));
}
}
depth += line.matches('{').count() as i32;
depth -= line.matches('}').count() as i32;
if depth < 0 {
depth = 0;
}
}

if variants.is_empty() {
None
} else {
Some(variants)
}
}
Comment thread
LayerDynamics marked this conversation as resolved.

/// Return the brace-delimited body of `enum <name>`, matching braces so nested
/// struct-variant `{ .. }` blocks don't terminate the scan early.
fn enum_body<'a>(src: &'a str, name: &str) -> Option<&'a str> {
let needle = format!("enum {name}");
let start = src.find(&needle)?;
let open = src[start..].find('{')? + start;

let bytes = src.as_bytes();
let mut depth = 0i32;
let mut body_start = open + 1;
let mut i = open;
while i < bytes.len() {
match bytes[i] {
b'{' => {
depth += 1;
if depth == 1 {
body_start = i + 1;
}
}
b'}' => {
depth -= 1;
if depth == 0 {
return Some(&src[body_start..i]);
}
}
_ => {}
}
i += 1;
}
None
}

/// If `line` begins a variant declaration, return its identifier. Accepts the
/// PascalCase name when it is followed by variant syntax — `{` (struct
/// variant), `(` (tuple variant), `,`, or end of line (unit variant).
fn variant_name(line: &str) -> Option<String> {
let first = line.chars().next()?;
if !first.is_ascii_uppercase() {
return None;
}
let end = line
.char_indices()
.find(|(_, c)| !(c.is_ascii_alphanumeric() || *c == '_'))
.map(|(idx, _)| idx)
.unwrap_or(line.len());
let ident = &line[..end];
let rest = line[end..].trim_start();
if rest.is_empty() || rest.starts_with('{') || rest.starts_with('(') || rest.starts_with(',') {
Some(ident.to_string())
} else {
None
}
}

/// Replicate clap's default subcommand rename (PascalCase -> kebab-case).
fn to_kebab(name: &str) -> String {
let mut out = String::new();
for (i, c) in name.chars().enumerate() {
if c.is_ascii_uppercase() {
if i != 0 {
out.push('-');
}
out.push(c.to_ascii_lowercase());
} else {
out.push(c);
}
}
out
}
22 changes: 21 additions & 1 deletion crates/forge-docs-check/tests/rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -250,9 +250,29 @@ fn counts_marker_is_authoritative() {
fn cli_command_flags_missing_subcommand() {
let mut fx = Fixture::new();
fx.add_crate("forge_cli");
// The clap command model is the source of truth (post-P5.2 migration).
// Includes doc comments, attributes, and a struct variant with nested
// braces to exercise the brace-matching parser.
fx.write(
"crates/forge_cli/src/main.rs",
"fn usage() { eprintln!(\"forge <dev|build|smelt> [options]\"); }\n",
r#"#[derive(Subcommand)]
enum Commands {
/// Run an app
Dev {
app_dir: PathBuf,
},
/// Build assets
Build {
app_dir: PathBuf,
},
/// AOT compile
Smelt {
app_dir: PathBuf,
#[arg(long, short)]
out: Option<PathBuf>,
},
}
"#,
);
// forge.md documents dev and build but not smelt.
fx.write(
Expand Down
1 change: 1 addition & 0 deletions crates/forge_cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ rustdoc-args = ["--document-private-items"]

[dependencies]
anyhow = "1"
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
toml = "0.8"

Expand Down
Loading
Loading