Add schema-first API docs drift detector (xtask check-docs)
Documentation.md's Lua API and IPC protocol sections are hand-written prose and have drifted from the actual bread.* binding / IPC method surface before. This adds a checked-in registry (api-schema.toml) of every current Lua binding and IPC method, plus a new xtask crate (`cargo run -p xtask -- check-docs`) that cross-checks it against breadd/src/lua/mod.rs, breadd/src/ipc/mod.rs, and Documentation.md itself, failing loudly on any mismatch. It's a drift detector, not a doc generator — Documentation.md's prose is untouched except for one genuine gap the bootstrap surfaced: bread.hyprland.eval had no mention anywhere in the docs, now given a one-line example alongside its siblings. CONTRIBUTING.md documents the resulting contributor workflow.
This commit is contained in:
parent
96639516b1
commit
c5e871694a
7 changed files with 1152 additions and 0 deletions
|
|
@ -67,6 +67,24 @@ cargo build --release --workspace
|
|||
cargo test --release --workspace
|
||||
```
|
||||
|
||||
### Keeping the API docs honest
|
||||
|
||||
`Documentation.md`'s Lua API and IPC protocol sections are hand-written and
|
||||
have drifted from the actual code before — there's a checked-in registry,
|
||||
`api-schema.toml`, plus an `xtask` checker that catches it happening again.
|
||||
|
||||
Whenever you add, rename, or remove a `bread.*` Lua binding
|
||||
(`breadd/src/lua/mod.rs`) or an IPC method (`breadd/src/ipc/mod.rs`):
|
||||
|
||||
1. Add/update/remove its entry in `api-schema.toml` to match.
|
||||
2. Add/update the corresponding section in `Documentation.md` (a
|
||||
`#### bread.<name>` heading for a Lua binding, or a row in the IPC
|
||||
Methods table for an IPC method).
|
||||
3. Run `cargo run -p xtask -- check-docs` before committing. It fails with
|
||||
a non-zero exit and a list of exactly what's out of sync — added but
|
||||
undocumented, stale in the schema, or missing a doc heading/row — if
|
||||
`api-schema.toml`, the code, and `Documentation.md` don't all agree.
|
||||
|
||||
## CI
|
||||
|
||||
- `dev-release.yml` — triggered on push to `main`.
|
||||
|
|
|
|||
9
Cargo.lock
generated
9
Cargo.lock
generated
|
|
@ -2414,6 +2414,15 @@ dependencies = [
|
|||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
"toml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus"
|
||||
version = "3.15.2"
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ members = [
|
|||
"breadd",
|
||||
"bread-cli",
|
||||
"bread-emit",
|
||||
"xtask",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
|
|
|
|||
|
|
@ -543,6 +543,10 @@ bread.hyprland.dispatch("exec", "kitty")
|
|||
-- Set a keyword
|
||||
bread.hyprland.keyword("monitor", "HDMI-A-1, 2560x1440, 0x0, 1")
|
||||
|
||||
-- Send a raw request to the Hyprland socket, e.g. to evaluate a config-file
|
||||
-- expression the way `hyprctl eval <expr>` does; returns the raw response string
|
||||
local result = bread.hyprland.eval("some expression")
|
||||
|
||||
-- Query compositor state (returns deserialized Lua tables)
|
||||
local win = bread.hyprland.active_window()
|
||||
local monitors = bread.hyprland.monitors()
|
||||
|
|
|
|||
500
api-schema.toml
Normal file
500
api-schema.toml
Normal file
|
|
@ -0,0 +1,500 @@
|
|||
# Bread Automation API schema — checked-in source-of-truth registry.
|
||||
#
|
||||
# This is Workstream F (scoped down) from the governance-hardening report:
|
||||
# a *drift detector*, not a doc generator. `Documentation.md`'s "Dictionary:
|
||||
# Lua API" section is hand-written prose (one `#### bread.<name>(...)`
|
||||
# heading per binding, with worked examples and edge-case notes) — nothing
|
||||
# here regenerates or reformats that prose. Instead, this file is the
|
||||
# checked-in list of every `bread.*` Lua binding and IPC method that is
|
||||
# supposed to exist right now, and `cargo run -p xtask -- check-docs`
|
||||
# cross-checks it against:
|
||||
#
|
||||
# 1. The actual bindings registered in breadd/src/lua/mod.rs
|
||||
# (`bread.set("name", ...)` calls, the nested `<x>_tbl.set(...)` calls
|
||||
# for state/profile/hyprland/widget/machine/fs/json/bluetooth, and the
|
||||
# handful of bindings defined via plain embedded Lua source rather than
|
||||
# `bread.set` — log/warn/error/debounce/spawn/wait/wait_any/wait_all/
|
||||
# workflow.*).
|
||||
# 2. The actual IPC methods dispatched in breadd/src/ipc/mod.rs's
|
||||
# `match req.method.as_str() { ... }` block, plus the specially-cased
|
||||
# `events.subscribe` streaming upgrade.
|
||||
# 3. Documentation.md itself, to make sure each entry here still has a
|
||||
# `#### bread.<name>` heading (Lua) or a row in the IPC Methods table
|
||||
# (IPC methods).
|
||||
#
|
||||
# Whenever you add, rename, or remove a `bread.*` binding or IPC method:
|
||||
# 1. Update this file to match.
|
||||
# 2. Update/add the corresponding section in Documentation.md.
|
||||
# 3. Run `cargo run -p xtask -- check-docs` before committing — it fails
|
||||
# loudly (non-zero exit) if the three are out of sync.
|
||||
#
|
||||
# `kind` is one of: "lua_function", "lua_table", "ipc_method".
|
||||
# `since` is the API version (Documentation.md's "API Stability &
|
||||
# Versioning" section) the binding/method was introduced in. Anything from
|
||||
# the original v1.0 baseline (no `*(Since: vX.Y)*` marker in Documentation.md)
|
||||
# is listed as "1.0" here.
|
||||
#
|
||||
# Format chosen: a single checked-in TOML file (this is the "a schema file
|
||||
# that's checked and diffed against the actual API surface, and CI fails the
|
||||
# build if they drift" option the source report names, as opposed to Rust
|
||||
# attribute macros — overkill for a ~30-binding surface with no existing
|
||||
# proc-macro infrastructure in this workspace). TOML specifically because
|
||||
# `toml = "0.8"` is already a dependency of breadd/bread-cli/bread-shared
|
||||
# (see breadd/src/core/config.rs, bread-cli/src/modules_mgmt.rs) — no new
|
||||
# format/parser needed anywhere in the ecosystem.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lua API — Events (breadd/src/lua/mod.rs install_api + install_wait_helper)
|
||||
|
||||
[[entry]]
|
||||
name = "on"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "once"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "filter"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "off"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "emit"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "wait"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "spawn"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "wait_any"
|
||||
kind = "lua_function"
|
||||
since = "1.2"
|
||||
|
||||
[[entry]]
|
||||
name = "wait_all"
|
||||
kind = "lua_function"
|
||||
since = "1.2"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lua API — Workflows (install_workflow_helpers) *(Since: v1.2)*
|
||||
|
||||
[[entry]]
|
||||
name = "workflow"
|
||||
kind = "lua_table"
|
||||
since = "1.2"
|
||||
|
||||
[[entry]]
|
||||
name = "workflow.define"
|
||||
kind = "lua_function"
|
||||
since = "1.2"
|
||||
|
||||
[[entry]]
|
||||
name = "workflow.start"
|
||||
kind = "lua_function"
|
||||
since = "1.2"
|
||||
|
||||
[[entry]]
|
||||
name = "workflow.step"
|
||||
kind = "lua_function"
|
||||
since = "1.2"
|
||||
|
||||
[[entry]]
|
||||
name = "workflow.status"
|
||||
kind = "lua_function"
|
||||
since = "1.2"
|
||||
|
||||
[[entry]]
|
||||
name = "workflow.list"
|
||||
kind = "lua_function"
|
||||
since = "1.2"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lua API — Widgets (widget_tbl) *(Since: v1.3)*
|
||||
|
||||
[[entry]]
|
||||
name = "widget"
|
||||
kind = "lua_table"
|
||||
since = "1.3"
|
||||
|
||||
[[entry]]
|
||||
name = "widget.register"
|
||||
kind = "lua_function"
|
||||
since = "1.3"
|
||||
|
||||
[[entry]]
|
||||
name = "widget.update"
|
||||
kind = "lua_function"
|
||||
since = "1.3"
|
||||
|
||||
[[entry]]
|
||||
name = "widget.remove"
|
||||
kind = "lua_function"
|
||||
since = "1.3"
|
||||
|
||||
[[entry]]
|
||||
name = "widget.list"
|
||||
kind = "lua_function"
|
||||
since = "1.3"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lua API — State (state_tbl)
|
||||
|
||||
[[entry]]
|
||||
name = "state"
|
||||
kind = "lua_table"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "state.get"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "state.monitors"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "state.active_workspace"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "state.active_window"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "state.devices"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "state.power"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "state.network"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "state.profile"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "state.watch"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lua API — Profiles (profile_tbl)
|
||||
|
||||
[[entry]]
|
||||
name = "profile"
|
||||
kind = "lua_table"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "profile.activate"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lua API — Execution, notifications, timers
|
||||
|
||||
[[entry]]
|
||||
name = "exec"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "exec_capture"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "notify"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "after"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "every"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "cancel"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lua API — Hyprland (hyprland_tbl)
|
||||
|
||||
[[entry]]
|
||||
name = "hyprland"
|
||||
kind = "lua_table"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "hyprland.dispatch"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "hyprland.keyword"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "hyprland.eval"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "hyprland.active_window"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "hyprland.monitors"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "hyprland.workspaces"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "hyprland.clients"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "hyprland.on_raw"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lua API — Module declaration
|
||||
|
||||
[[entry]]
|
||||
name = "module"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lua API — Machine and filesystem (machine_tbl / fs_tbl / json_tbl)
|
||||
|
||||
[[entry]]
|
||||
name = "machine"
|
||||
kind = "lua_table"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "machine.name"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "machine.tags"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "machine.has_tag"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "fs"
|
||||
kind = "lua_table"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "fs.write"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "fs.read"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "fs.exists"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "fs.readlink"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "fs.expand"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "json"
|
||||
kind = "lua_table"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "json.decode"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lua API — Bluetooth (bluetooth_tbl)
|
||||
|
||||
[[entry]]
|
||||
name = "bluetooth"
|
||||
kind = "lua_table"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "bluetooth.power"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "bluetooth.powered"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "bluetooth.connect"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "bluetooth.disconnect"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "bluetooth.scan"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "bluetooth.devices"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lua API — Utilities (install_log_helpers / install_debounce)
|
||||
|
||||
[[entry]]
|
||||
name = "log"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "warn"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "error"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "debounce"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# IPC methods (breadd/src/ipc/mod.rs handle_request + events.subscribe)
|
||||
|
||||
[[entry]]
|
||||
name = "ping"
|
||||
kind = "ipc_method"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "health"
|
||||
kind = "ipc_method"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "state.get"
|
||||
kind = "ipc_method"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "state.dump"
|
||||
kind = "ipc_method"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "modules.list"
|
||||
kind = "ipc_method"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "modules.reload"
|
||||
kind = "ipc_method"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "profile.list"
|
||||
kind = "ipc_method"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "profile.activate"
|
||||
kind = "ipc_method"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "emit"
|
||||
kind = "ipc_method"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "events.subscribe"
|
||||
kind = "ipc_method"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "events.replay"
|
||||
kind = "ipc_method"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "workflows.list"
|
||||
kind = "ipc_method"
|
||||
since = "1.2"
|
||||
|
||||
[[entry]]
|
||||
name = "widgets.list"
|
||||
kind = "ipc_method"
|
||||
since = "1.3"
|
||||
14
xtask/Cargo.toml
Normal file
14
xtask/Cargo.toml
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
[package]
|
||||
name = "xtask"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
name = "xtask"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
serde.workspace = true
|
||||
toml = "0.8"
|
||||
606
xtask/src/main.rs
Normal file
606
xtask/src/main.rs
Normal file
|
|
@ -0,0 +1,606 @@
|
|||
//! Repo maintenance tasks that don't belong in the `breadd`/`bread-cli`
|
||||
//! binaries themselves.
|
||||
//!
|
||||
//! Currently just `check-docs`: a drift detector between the actual
|
||||
//! `bread.*` Lua binding surface + IPC method surface (as implemented in
|
||||
//! `breadd/src/lua/mod.rs` / `breadd/src/ipc/mod.rs`) and the checked-in
|
||||
//! registry at `api-schema.toml`, cross-referenced against `Documentation.md`.
|
||||
//! See `api-schema.toml`'s header comment for why this exists and why it's a
|
||||
//! *drift detector*, not a doc generator.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::ExitCode;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Deserialize;
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
match args.first().map(String::as_str) {
|
||||
Some("check-docs") => match run_check_docs() {
|
||||
Ok(true) => ExitCode::SUCCESS,
|
||||
Ok(false) => ExitCode::FAILURE,
|
||||
Err(err) => {
|
||||
eprintln!("xtask check-docs: error: {err:#}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
},
|
||||
Some(other) => {
|
||||
eprintln!("unknown xtask command '{other}'\n{}", usage());
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
None => {
|
||||
eprintln!("{}", usage());
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn usage() -> &'static str {
|
||||
"usage: cargo run -p xtask -- <command>\n\n\
|
||||
Available commands:\n \
|
||||
check-docs verify api-schema.toml matches breadd's Lua/IPC surface and Documentation.md"
|
||||
}
|
||||
|
||||
fn run_check_docs() -> Result<bool> {
|
||||
let root = repo_root()?;
|
||||
|
||||
let lua_src = std::fs::read_to_string(root.join("breadd/src/lua/mod.rs"))
|
||||
.context("reading breadd/src/lua/mod.rs")?;
|
||||
let ipc_src = std::fs::read_to_string(root.join("breadd/src/ipc/mod.rs"))
|
||||
.context("reading breadd/src/ipc/mod.rs")?;
|
||||
let schema_toml = std::fs::read_to_string(root.join("api-schema.toml"))
|
||||
.context("reading api-schema.toml")?;
|
||||
let doc_md =
|
||||
std::fs::read_to_string(root.join("Documentation.md")).context("reading Documentation.md")?;
|
||||
|
||||
let report = check(&lua_src, &ipc_src, &schema_toml, &doc_md)?;
|
||||
report.print();
|
||||
Ok(report.is_clean())
|
||||
}
|
||||
|
||||
fn repo_root() -> Result<PathBuf> {
|
||||
// xtask's own Cargo.toml lives at <repo_root>/xtask/Cargo.toml, so its
|
||||
// parent is the workspace root regardless of the caller's cwd (`cargo
|
||||
// run -p xtask` sets CARGO_MANIFEST_DIR to xtask/, not the invocation dir).
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.map(Path::to_path_buf)
|
||||
.context("xtask has no parent directory (unexpected workspace layout)")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schema
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Schema {
|
||||
#[serde(default)]
|
||||
entry: Vec<SchemaEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct SchemaEntry {
|
||||
name: String,
|
||||
kind: String,
|
||||
#[serde(default)]
|
||||
since: String,
|
||||
}
|
||||
|
||||
const VALID_KINDS: &[&str] = &["lua_function", "lua_table", "ipc_method"];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extraction: breadd/src/lua/mod.rs -> the current bread.* Lua API surface.
|
||||
//
|
||||
// This is deliberately line/substring scanning, not a real Lua or Rust
|
||||
// parser (see api-schema.toml's header comment) — precise enough because
|
||||
// `install_api` and its `install_*_helpers` siblings follow a small, stable
|
||||
// set of textual patterns:
|
||||
//
|
||||
// 1. `bread.set("name", ...)` - a top-level binding (function or,
|
||||
// for the known sub-table variables
|
||||
// below, a table).
|
||||
// 2. `<x>_tbl.set("name", ...)` - a method nested under table `<x>`.
|
||||
// 3. `function _bread.name(` /
|
||||
// `function bread.name(` - a plain-Lua-defined top-level
|
||||
// function (log/warn/error/debounce).
|
||||
// 4. `bread.name = function` - ditto, assignment form
|
||||
// (spawn/wait/wait_any/wait_all).
|
||||
// 5. `bread.workflow = {}` and
|
||||
// `bread.workflow.name = function` - the `bread.workflow` table and its
|
||||
// members.
|
||||
|
||||
/// Sub-table variables built in `install_api` and registered onto the
|
||||
/// `bread` global, mapped to the dotted parent name they hang off.
|
||||
/// Deliberately excludes `module_tbl` / `store_tbl`: those back the
|
||||
/// per-module `M` object returned by `bread.module(...)` (i.e. `M.store.get`
|
||||
/// etc.), which is a different namespace from `bread.*` itself.
|
||||
const TABLE_VARS: &[(&str, &str)] = &[
|
||||
("state_tbl", "state"),
|
||||
("profile_tbl", "profile"),
|
||||
("hyprland_tbl", "hyprland"),
|
||||
("widget_tbl", "widget"),
|
||||
("machine_tbl", "machine"),
|
||||
("fs_tbl", "fs"),
|
||||
("json_tbl", "json"),
|
||||
("bluetooth_tbl", "bluetooth"),
|
||||
];
|
||||
|
||||
/// Read a bare identifier (`[A-Za-z0-9_]+`) starting at byte offset `start`.
|
||||
fn ident_at(text: &str, start: usize) -> &str {
|
||||
let rest = &text[start..];
|
||||
let end = rest
|
||||
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
|
||||
.unwrap_or(rest.len());
|
||||
&rest[..end]
|
||||
}
|
||||
|
||||
/// Read up to (not including) the next `"` starting at byte offset `start`.
|
||||
fn ident_upto_quote(text: &str, start: usize) -> &str {
|
||||
let rest = &text[start..];
|
||||
let end = rest.find('"').unwrap_or(rest.len());
|
||||
&rest[..end]
|
||||
}
|
||||
|
||||
fn extract_lua_bindings(src: &str) -> BTreeSet<(String, String)> {
|
||||
let mut out = BTreeSet::new();
|
||||
|
||||
// 1. Top-level `bread.set("name", ...)`.
|
||||
for (idx, _) in src.match_indices("bread.set(\"") {
|
||||
let name = ident_upto_quote(src, idx + "bread.set(\"".len());
|
||||
if name.is_empty() || name.starts_with("__") {
|
||||
continue; // internal Rust<->Lua bridge fn (e.g. __log_info), not public API
|
||||
}
|
||||
let is_table = TABLE_VARS.iter().any(|(_, parent)| *parent == name);
|
||||
out.insert((
|
||||
(if is_table { "lua_table" } else { "lua_function" }).to_string(),
|
||||
name.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// 2. Nested `<x>_tbl.set("name", ...)` for the known sub-tables.
|
||||
for (var, parent) in TABLE_VARS {
|
||||
let prefix = format!("{var}.set(\"");
|
||||
for (idx, _) in src.match_indices(prefix.as_str()) {
|
||||
let name = ident_upto_quote(src, idx + prefix.len());
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
out.insert(("lua_function".to_string(), format!("{parent}.{name}")));
|
||||
}
|
||||
}
|
||||
|
||||
// 3. `function _bread.name(` / `function bread.name(`.
|
||||
for prefix in ["function _bread.", "function bread."] {
|
||||
for (idx, _) in src.match_indices(prefix) {
|
||||
let name = ident_at(src, idx + prefix.len());
|
||||
if !name.is_empty() {
|
||||
out.insert(("lua_function".to_string(), name.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. `bread.name = function` (top-level only; `bread.workflow.*` is
|
||||
// handled separately in step 5 since it's a nested table's members).
|
||||
for (idx, _) in src.match_indices("bread.") {
|
||||
let name_start = idx + "bread.".len();
|
||||
let name = ident_at(src, name_start);
|
||||
if name.is_empty() || name == "workflow" {
|
||||
continue;
|
||||
}
|
||||
if src[name_start + name.len()..].trim_start().starts_with("= function") {
|
||||
out.insert(("lua_function".to_string(), name.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
// 5. `bread.workflow = {}` and `bread.workflow.name = function`.
|
||||
if src.contains("bread.workflow = {}") {
|
||||
out.insert(("lua_table".to_string(), "workflow".to_string()));
|
||||
}
|
||||
for (idx, _) in src.match_indices("bread.workflow.") {
|
||||
let name_start = idx + "bread.workflow.".len();
|
||||
let name = ident_at(src, name_start);
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if src[name_start + name.len()..].trim_start().starts_with("= function") {
|
||||
out.insert(("lua_function".to_string(), format!("workflow.{name}")));
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extraction: breadd/src/ipc/mod.rs -> the current IPC method surface.
|
||||
|
||||
fn extract_ipc_methods(src: &str) -> BTreeSet<String> {
|
||||
let mut out = BTreeSet::new();
|
||||
|
||||
// `events.subscribe` is special-cased ahead of the dispatch `match`
|
||||
// (it upgrades the connection to a streaming socket instead of
|
||||
// returning a single response), so it never appears as a match arm.
|
||||
if src.contains("req.method == \"events.subscribe\"") {
|
||||
out.insert("events.subscribe".to_string());
|
||||
}
|
||||
|
||||
let Some(match_start) = src.find("match req.method.as_str() {") else {
|
||||
return out;
|
||||
};
|
||||
|
||||
// Track brace depth so a `"literal" => ...` pattern belonging to some
|
||||
// *other*, nested match inside an arm's body (e.g. the `"emit"` arm's
|
||||
// own `match source_str { "terminal" => ..., "git" => ... }`) isn't
|
||||
// mistaken for a top-level IPC method arm. Only depth == 1 (directly
|
||||
// inside the outer `match req.method.as_str() { ... }`) counts.
|
||||
let mut depth: i32 = 0;
|
||||
for line in src[match_start..].lines() {
|
||||
let pre_depth = depth;
|
||||
let trimmed = line.trim_start();
|
||||
|
||||
if pre_depth == 1 {
|
||||
if let Some(rest) = trimmed.strip_prefix('"') {
|
||||
if let Some(end) = rest.find('"') {
|
||||
let name = &rest[..end];
|
||||
if rest[end + 1..].trim_start().starts_with("=>") {
|
||||
out.insert(name.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
depth += line.matches('{').count() as i32;
|
||||
depth -= line.matches('}').count() as i32;
|
||||
if pre_depth >= 1 && depth <= 0 {
|
||||
break; // closed the outer match block
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Documentation.md cross-checks
|
||||
|
||||
/// Slice out a `## `-level section (from its heading up to, but not
|
||||
/// including, the next `## `-level heading).
|
||||
fn section<'a>(doc: &'a str, heading: &str) -> &'a str {
|
||||
let Some(start) = doc.find(heading) else {
|
||||
return "";
|
||||
};
|
||||
let rest = &doc[start..];
|
||||
match rest[3..].find("\n## ") {
|
||||
Some(i) => &rest[..i + 3],
|
||||
None => rest,
|
||||
}
|
||||
}
|
||||
|
||||
fn lua_api_section(doc: &str) -> &str {
|
||||
section(doc, "## Dictionary: Lua API")
|
||||
}
|
||||
|
||||
fn ipc_section(doc: &str) -> &str {
|
||||
section(doc, "## Dictionary: IPC protocol")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Report
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct CheckReport {
|
||||
/// In code, not in api-schema.toml.
|
||||
missing_from_schema: Vec<(String, String)>,
|
||||
/// In api-schema.toml, no longer in code.
|
||||
stale_in_schema: Vec<(String, String)>,
|
||||
/// In api-schema.toml (and in code), but Documentation.md has no
|
||||
/// heading/row for it.
|
||||
undocumented: Vec<(String, String)>,
|
||||
/// Schema entries with an unrecognized `kind`.
|
||||
bad_kind: Vec<(String, String)>,
|
||||
/// Schema entries with an empty `since`.
|
||||
missing_since: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl CheckReport {
|
||||
fn is_clean(&self) -> bool {
|
||||
self.missing_from_schema.is_empty()
|
||||
&& self.stale_in_schema.is_empty()
|
||||
&& self.undocumented.is_empty()
|
||||
&& self.bad_kind.is_empty()
|
||||
&& self.missing_since.is_empty()
|
||||
}
|
||||
|
||||
fn print(&self) {
|
||||
if self.is_clean() {
|
||||
println!("check-docs: OK — api-schema.toml matches breadd's Lua/IPC surface and Documentation.md.");
|
||||
return;
|
||||
}
|
||||
|
||||
if !self.bad_kind.is_empty() {
|
||||
println!("Schema entries with an unrecognized `kind` (expected one of {VALID_KINDS:?}):");
|
||||
for (kind, name) in &self.bad_kind {
|
||||
println!(" - {name} (kind = \"{kind}\")");
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
if !self.missing_from_schema.is_empty() {
|
||||
println!("Added but undocumented in api-schema.toml (present in code, missing from schema):");
|
||||
for (kind, name) in &self.missing_from_schema {
|
||||
println!(" - [{kind}] {name}");
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
if !self.stale_in_schema.is_empty() {
|
||||
println!("Stale in api-schema.toml (no longer found in code):");
|
||||
for (kind, name) in &self.stale_in_schema {
|
||||
println!(" - [{kind}] {name}");
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
if !self.undocumented.is_empty() {
|
||||
println!("In api-schema.toml but missing from Documentation.md (no `#### bread.<name>` heading for a lua_function/lua_table, or no `<name>` row in the IPC Methods table):");
|
||||
for (kind, name) in &self.undocumented {
|
||||
println!(" - [{kind}] {name}");
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
if !self.missing_since.is_empty() {
|
||||
println!("Schema entries with an empty `since`:");
|
||||
for (kind, name) in &self.missing_since {
|
||||
println!(" - [{kind}] {name}");
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
println!("check-docs: FAILED — see above.");
|
||||
}
|
||||
}
|
||||
|
||||
fn check(lua_src: &str, ipc_src: &str, schema_toml: &str, doc_md: &str) -> Result<CheckReport> {
|
||||
let schema: Schema = toml::from_str(schema_toml).context("parsing api-schema.toml")?;
|
||||
|
||||
let mut report = CheckReport::default();
|
||||
|
||||
let mut schema_set: BTreeSet<(String, String)> = BTreeSet::new();
|
||||
for entry in &schema.entry {
|
||||
if !VALID_KINDS.contains(&entry.kind.as_str()) {
|
||||
report.bad_kind.push((entry.kind.clone(), entry.name.clone()));
|
||||
continue;
|
||||
}
|
||||
if entry.since.trim().is_empty() {
|
||||
report
|
||||
.missing_since
|
||||
.push((entry.kind.clone(), entry.name.clone()));
|
||||
}
|
||||
schema_set.insert((entry.kind.clone(), entry.name.clone()));
|
||||
}
|
||||
|
||||
// --- code vs schema: Lua ---
|
||||
let code_lua = extract_lua_bindings(lua_src);
|
||||
let schema_lua: BTreeSet<(String, String)> = schema_set
|
||||
.iter()
|
||||
.filter(|(kind, _)| kind != "ipc_method")
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
for entry in code_lua.difference(&schema_lua) {
|
||||
report.missing_from_schema.push(entry.clone());
|
||||
}
|
||||
for entry in schema_lua.difference(&code_lua) {
|
||||
report.stale_in_schema.push(entry.clone());
|
||||
}
|
||||
|
||||
// --- code vs schema: IPC ---
|
||||
let code_ipc = extract_ipc_methods(ipc_src);
|
||||
let schema_ipc: BTreeSet<String> = schema_set
|
||||
.iter()
|
||||
.filter(|(kind, _)| kind == "ipc_method")
|
||||
.map(|(_, name)| name.clone())
|
||||
.collect();
|
||||
|
||||
for name in code_ipc.difference(&schema_ipc) {
|
||||
report
|
||||
.missing_from_schema
|
||||
.push(("ipc_method".to_string(), name.clone()));
|
||||
}
|
||||
for name in schema_ipc.difference(&code_ipc) {
|
||||
report
|
||||
.stale_in_schema
|
||||
.push(("ipc_method".to_string(), name.clone()));
|
||||
}
|
||||
|
||||
// --- schema vs Documentation.md ---
|
||||
let lua_section = lua_api_section(doc_md);
|
||||
let ipc_tbl_section = ipc_section(doc_md);
|
||||
for entry in &schema.entry {
|
||||
if !VALID_KINDS.contains(&entry.kind.as_str()) {
|
||||
continue; // already reported above
|
||||
}
|
||||
let documented = if entry.kind == "ipc_method" {
|
||||
ipc_tbl_section.contains(&format!("`{}`", entry.name))
|
||||
} else {
|
||||
lua_section.contains(&format!("bread.{}", entry.name))
|
||||
};
|
||||
if !documented {
|
||||
report
|
||||
.undocumented
|
||||
.push((entry.kind.clone(), entry.name.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
report.missing_from_schema.sort();
|
||||
report.stale_in_schema.sort();
|
||||
report.undocumented.sort();
|
||||
report.bad_kind.sort();
|
||||
report.missing_since.sort();
|
||||
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const LUA_SRC: &str = r#"
|
||||
bread.set("on", on_fn)?;
|
||||
bread.set("state", state_tbl)?;
|
||||
state_tbl.set("get", get_fn)?;
|
||||
state_tbl.set("watch", watch_fn)?;
|
||||
bread.set("__log_info", info_fn)?;
|
||||
function bread.debounce(delay_ms, fn)
|
||||
bread.spawn = function(fn)
|
||||
bread.workflow = {}
|
||||
bread.workflow.define = function(name, fn)
|
||||
"#;
|
||||
|
||||
const IPC_SRC: &str = r#"
|
||||
if req.method == "events.subscribe" {
|
||||
}
|
||||
let result = match req.method.as_str() {
|
||||
"ping" => Ok(json!({ "ok": true })),
|
||||
"emit" => {
|
||||
let source = match source_str {
|
||||
"terminal" => AdapterSource::Terminal,
|
||||
"git" => AdapterSource::Git,
|
||||
other => AdapterSource::App(other.to_string()),
|
||||
};
|
||||
Ok(json!({ "emitted": true }))
|
||||
}
|
||||
_ => Err("unknown method".to_string()),
|
||||
};
|
||||
"#;
|
||||
|
||||
const DOC_MD: &str = r#"
|
||||
## Dictionary: Lua API
|
||||
|
||||
#### `bread.on(pattern, fn) -> id`
|
||||
#### `bread.state.get(path)`
|
||||
#### `bread.state.watch(path, fn) -> id`
|
||||
#### `bread.debounce(delay_ms, fn) -> wrapped_fn`
|
||||
#### `bread.spawn(fn)`
|
||||
### Workflows
|
||||
#### `bread.workflow.define(name, fn)`
|
||||
|
||||
## Dictionary: IPC protocol
|
||||
|
||||
| Method | Params | Description |
|
||||
|--------|--------|-------------|
|
||||
| `ping` | - | Connectivity check |
|
||||
| `emit` | - | Inject an event |
|
||||
| `events.subscribe` | - | Upgrade to streaming mode |
|
||||
"#;
|
||||
|
||||
fn schema_toml_for(entries: &[(&str, &str, &str)]) -> String {
|
||||
let mut out = String::new();
|
||||
for (name, kind, since) in entries {
|
||||
out.push_str(&format!(
|
||||
"[[entry]]\nname = \"{name}\"\nkind = \"{kind}\"\nsince = \"{since}\"\n\n"
|
||||
));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn full_schema() -> String {
|
||||
schema_toml_for(&[
|
||||
("on", "lua_function", "1.0"),
|
||||
("state", "lua_table", "1.0"),
|
||||
("state.get", "lua_function", "1.0"),
|
||||
("state.watch", "lua_function", "1.0"),
|
||||
("debounce", "lua_function", "1.0"),
|
||||
("spawn", "lua_function", "1.0"),
|
||||
("workflow", "lua_table", "1.2"),
|
||||
("workflow.define", "lua_function", "1.2"),
|
||||
("ping", "ipc_method", "1.0"),
|
||||
("emit", "ipc_method", "1.0"),
|
||||
("events.subscribe", "ipc_method", "1.0"),
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_lua_bindings_precisely() {
|
||||
let got = extract_lua_bindings(LUA_SRC);
|
||||
let want: BTreeSet<(String, String)> = [
|
||||
("lua_function", "on"),
|
||||
("lua_table", "state"),
|
||||
("lua_function", "state.get"),
|
||||
("lua_function", "state.watch"),
|
||||
("lua_function", "debounce"),
|
||||
("lua_function", "spawn"),
|
||||
("lua_table", "workflow"),
|
||||
("lua_function", "workflow.define"),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(k, n)| (k.to_string(), n.to_string()))
|
||||
.collect();
|
||||
assert_eq!(got, want, "must exclude __-prefixed internals");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_ipc_methods_without_leaking_nested_match_arms() {
|
||||
let got = extract_ipc_methods(IPC_SRC);
|
||||
let want: BTreeSet<String> = ["events.subscribe", "ping", "emit"]
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
got, want,
|
||||
"must not pick up \"terminal\"/\"git\" from the nested `match source_str` inside the emit arm"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_state_passes() {
|
||||
let report = check(LUA_SRC, IPC_SRC, &full_schema(), DOC_MD).unwrap();
|
||||
assert!(report.is_clean(), "{report:#?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renamed_schema_entry_is_caught_as_drift() {
|
||||
// Simulate a contributor renaming `bread.debounce` in code without
|
||||
// updating api-schema.toml: schema still says "debounce", code no
|
||||
// longer does (only "debounce_v2" now exists).
|
||||
let renamed_lua_src = LUA_SRC.replace("bread.debounce", "bread.debounce_v2");
|
||||
let report = check(&renamed_lua_src, IPC_SRC, &full_schema(), DOC_MD).unwrap();
|
||||
assert!(!report.is_clean());
|
||||
assert!(report
|
||||
.missing_from_schema
|
||||
.contains(&("lua_function".to_string(), "debounce_v2".to_string())));
|
||||
assert!(report
|
||||
.stale_in_schema
|
||||
.contains(&("lua_function".to_string(), "debounce".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removed_ipc_method_is_caught_as_drift() {
|
||||
let without_emit = IPC_SRC.replacen("\"emit\" => {", "\"emit_removed\" => {", 1);
|
||||
let report = check(LUA_SRC, &without_emit, &full_schema(), DOC_MD).unwrap();
|
||||
assert!(!report.is_clean());
|
||||
assert!(report
|
||||
.stale_in_schema
|
||||
.contains(&("ipc_method".to_string(), "emit".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_doc_heading_is_caught_as_drift() {
|
||||
let doc_without_spawn_heading = DOC_MD.replace("#### `bread.spawn(fn)`\n", "");
|
||||
let report = check(LUA_SRC, IPC_SRC, &full_schema(), &doc_without_spawn_heading).unwrap();
|
||||
assert!(!report.is_clean());
|
||||
assert!(report
|
||||
.undocumented
|
||||
.contains(&("lua_function".to_string(), "spawn".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_kind_is_rejected() {
|
||||
let bad_schema = schema_toml_for(&[("on", "lua_thing", "1.0")]);
|
||||
let report = check(LUA_SRC, IPC_SRC, &bad_schema, DOC_MD).unwrap();
|
||||
assert!(!report.is_clean());
|
||||
assert!(report
|
||||
.bad_kind
|
||||
.contains(&("lua_thing".to_string(), "on".to_string())));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue