Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
31 changes: 31 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,19 @@ jobs:
target/
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}

# The `v8` crate's build script downloads a large prebuilt static lib from
# GitHub releases into ~/.cargo/.rusty_v8 (NOT covered by the cargo cache
# above). That download intermittently 504s and fails the job. Cache it
# separately with a prefix restore-key so the binary survives Cargo.lock
# changes (the v8 version rarely moves) and is fetched at most once.
- name: Cache rusty_v8 prebuilt
uses: actions/cache@v4
with:
path: ~/.cargo/.rusty_v8/
key: ${{ runner.os }}-rusty_v8-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-rusty_v8-

- name: Run cargo check
run: cargo check --workspace

Expand Down Expand Up @@ -63,6 +76,15 @@ jobs:
target/
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}

# See the check job: cache the v8 prebuilt to dodge GitHub-releases 504s.
- name: Cache rusty_v8 prebuilt
uses: actions/cache@v4
with:
path: ~/.cargo/.rusty_v8/
key: ${{ runner.os }}-rusty_v8-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-rusty_v8-

- name: Install system dependencies (Linux)
if: runner.os == 'Linux'
run: |
Expand Down Expand Up @@ -113,5 +135,14 @@ jobs:
target/
key: ${{ runner.os }}-cargo-clippy-${{ hashFiles('**/Cargo.lock') }}

# See the check job: cache the v8 prebuilt to dodge GitHub-releases 504s.
- name: Cache rusty_v8 prebuilt
uses: actions/cache@v4
with:
path: ~/.cargo/.rusty_v8/
key: ${{ runner.os }}-rusty_v8-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-rusty_v8-

- name: Run clippy
run: cargo clippy --workspace -- -D warnings
5 changes: 5 additions & 0 deletions .github/workflows/cross-platform.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,11 @@ jobs:
with:
shared-key: xplat
cache-all-crates: "true"
# The `v8` crate's build script downloads a large prebuilt static lib
# from GitHub releases into ~/.cargo/.rusty_v8; that download
# intermittently 504s. rust-cache doesn't cover that dir by default,
# so add it explicitly — fetched at most once, then reused per OS.
cache-directories: ~/.cargo/.rusty_v8

# Debug profile keeps debug_assertions on, which compiles the
# `#[cfg(any(debug_assertions, feature = "devtools"))]` DevTools paths.
Expand Down
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.

132 changes: 115 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,110 @@ 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).
///
/// This is a simple per-uppercase-boundary split, which matches clap's
/// `heck`-based kebab-casing for single-word variants (every current forge
/// subcommand). It diverges only on multi-word acronyms — `HTTPServer` here
/// becomes `h-t-t-p-server` where clap/heck emits `http-server`. No forge
/// subcommand triggers that, so the simpler form is kept intentionally.
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
}
Loading
Loading