bynkc/cli.rs
1//! The `bynkc` command-line interface definition.
2//!
3//! The clap types live here (rather than in `main.rs`) so they are the single
4//! source of truth for both the binary and the generated CLI reference page
5//! `site/src/content/docs/docs/cli.md`. [`render_markdown`] walks the
6//! clap command tree;
7//! the test `tests/cli_reference.rs` checks the page is up to date.
8
9use std::path::PathBuf;
10
11use clap::{CommandFactory, Parser, Subcommand, ValueEnum};
12
13use crate::BuildTarget;
14
15#[derive(Parser, Debug)]
16#[command(name = "bynkc", version, about = "The Bynk compiler", long_about = None)]
17pub struct Cli {
18 #[command(subcommand)]
19 pub command: Command,
20}
21
22/// v0.38 (ADR 0071): `bynkc check --format` selector.
23#[derive(Copy, Clone, Debug, PartialEq, Eq, Default, ValueEnum)]
24pub enum DiagFormat {
25 /// Ariadne rendering with full source context (the default).
26 #[default]
27 Rich,
28 /// One terse `path:line:col: severity[category]: message` line per
29 /// diagnostic — for the VS Code problem-matcher, CI, and scripts.
30 Short,
31}
32
33/// v0.59: `bynkc test --format` selector. A per-command subset whose value
34/// names match [`DiagFormat`] (`rich` is the human rendering across `bynkc`),
35/// rather than sharing the enum — `test` has no `short` behaviour yet, so it
36/// must not expose a value that parses but does nothing.
37#[derive(Copy, Clone, Debug, PartialEq, Eq, Default, ValueEnum)]
38pub enum TestFormat {
39 /// The grouped `✓ / ✗` human output (the default; unchanged behaviour).
40 #[default]
41 Rich,
42 /// A single pinned JSON document of results, for tooling and CI.
43 Json,
44}
45
46#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
47pub enum CliTarget {
48 /// Single-bundle output (the default). Cross-context calls compile to
49 /// direct function invocation.
50 Bundle,
51 /// One Cloudflare Worker per context. Cross-context calls go over
52 /// Service Bindings using a JSON wire format.
53 Workers,
54}
55
56impl From<CliTarget> for BuildTarget {
57 fn from(t: CliTarget) -> Self {
58 match t {
59 CliTarget::Bundle => BuildTarget::Bundle,
60 CliTarget::Workers => BuildTarget::Workers,
61 }
62 }
63}
64
65/// v0.108 (in-browser track, slice 1): the emitted artefact language. `ts` (the
66/// default and primary output) writes the typed TypeScript modules; `js` writes
67/// the same modules with their types stripped — an *emit-then-strip* JavaScript
68/// artefact (ADR 0137) runnable with no `tsc` in the loop. Orthogonal to
69/// `--target` (topology) and `--platform` (binding).
70#[derive(Copy, Clone, Debug, PartialEq, Eq, Default, ValueEnum)]
71pub enum EmitFormat {
72 /// TypeScript modules (the default, primary artefact).
73 #[default]
74 Ts,
75 /// JavaScript modules, types stripped (no `tsc` dependency).
76 Js,
77}
78
79/// v0.17: the deploy platform that selects the `bynk` surface binding. Distinct
80/// from [`CliTarget`] (the emit topology). v0.18 adds `node`.
81#[derive(Copy, Clone, Debug, PartialEq, Eq, Default, ValueEnum)]
82pub enum CliPlatform {
83 /// Cloudflare Workers runtime (the default).
84 #[default]
85 Cloudflare,
86 /// Node.js (≥ [`NODE_MAJOR_FLOOR`](crate::NODE_MAJOR_FLOOR)) runtime (v0.18).
87 Node,
88 /// The browser — the `bynk` surface over Web APIs, for the in-browser
89 /// REPL/playground (v0.108). `Bundle` topology only; `Fetch`/`Secrets` are
90 /// withheld (see ADR 0138).
91 Browser,
92}
93
94impl From<CliPlatform> for crate::Platform {
95 fn from(p: CliPlatform) -> Self {
96 match p {
97 CliPlatform::Cloudflare => crate::Platform::Cloudflare,
98 CliPlatform::Node => crate::Platform::Node,
99 CliPlatform::Browser => crate::Platform::Browser,
100 }
101 }
102}
103
104#[derive(Subcommand, Debug)]
105pub enum Command {
106 /// Compile a `.bynk` file (single-file commons) to a TypeScript file,
107 /// or a directory project to a tree of TypeScript files mirroring the
108 /// source layout.
109 Compile {
110 /// Input `.bynk` file, or directory project root.
111 input: PathBuf,
112 /// Output `.ts` file (for single-file input) or output root
113 /// directory (for project input).
114 #[arg(short, long)]
115 output: PathBuf,
116 /// Build target. `bundle` (default) produces a single deployment
117 /// unit; `workers` produces one Cloudflare Worker per context with
118 /// Service Binding plumbing (v0.8).
119 #[arg(long, value_enum, default_value = "bundle")]
120 target: CliTarget,
121 /// Deploy platform selecting the `bynk` surface binding (v0.17). A new
122 /// axis, distinct from `--target`: `cloudflare` (default), `node`, or
123 /// `browser` (the in-browser playground binding; `Bundle` topology only).
124 #[arg(long, value_enum, default_value = "cloudflare")]
125 platform: CliPlatform,
126 /// Artefact language (v0.108). `ts` (default) writes typed TypeScript;
127 /// `js` writes the same modules with types stripped — a JavaScript
128 /// artefact that runs with no `tsc` in the loop (ADR 0137).
129 #[arg(long, value_enum, default_value = "ts")]
130 emit: EmitFormat,
131 },
132 /// Type-check a `.bynk` file or project without writing output.
133 Check {
134 /// Input `.bynk` file or project root.
135 input: PathBuf,
136 /// Diagnostic output format. `rich` (default) is the ariadne
137 /// source-context rendering; `short` emits one terse
138 /// `path:line:col: severity[category]: message` line per diagnostic,
139 /// for tooling (the VS Code problem-matcher, CI, scripts).
140 #[arg(long, value_enum, default_value = "rich")]
141 format: DiagFormat,
142 },
143 /// Format `.bynk` source files in place. Passing `-` reads from stdin
144 /// and writes to stdout.
145 Fmt {
146 /// Files to format. Use `-` for stdin → stdout.
147 inputs: Vec<PathBuf>,
148 /// Check formatting without writing changes. Exits non-zero if any
149 /// file is not already canonical.
150 #[arg(long)]
151 check: bool,
152 },
153 /// Discover and run test declarations in a project. Compiles the project
154 /// (including all generated `tests/*.test.ts` modules), then invokes
155 /// Node.js on the aggregated runner script. Requires `tsc` and `node`
156 /// to be on PATH.
157 Test {
158 /// Input project root directory. Defaults to the current directory.
159 #[arg(default_value = ".")]
160 input: PathBuf,
161 /// Where to write compiled TypeScript test runner modules.
162 /// Defaults to `<input>/out`.
163 #[arg(short, long)]
164 output: Option<PathBuf>,
165 /// Skip the runner invocation. With `--format rich` this emits the
166 /// generated test files (for CI flows that drive the runner separately);
167 /// with `--format json` it emits a discovery document listing every
168 /// suite and case (each `outcome: "discovered"`) without running them —
169 /// a pure compile, no `tsc`/Node.
170 #[arg(long)]
171 no_run: bool,
172 /// Output format. `rich` (default) is the grouped ✓ / ✗ human output;
173 /// `json` is a single pinned JSON document of results, for tooling.
174 #[arg(long, value_enum, default_value = "rich")]
175 format: TestFormat,
176 /// Compile a debug build and launch the test runner under Node's
177 /// inspector (`node --inspect-brk`), printing the inspector URL for a
178 /// JavaScript debugger to attach (slice 2, ADR 0104). The emitted `.ts`
179 /// runs directly under Node's line-preserving type-stripping, so source
180 /// maps resolve breakpoints back to `.bynk`. Requires Node ≥ 22.18 (or
181 /// ≥ 23.6 unflagged). Does not run `tsc`.
182 #[arg(long)]
183 inspect: bool,
184 /// v0.114: the root seed for generative `property` tests, as hex (e.g.
185 /// `0x5f3a`). A failing property prints the seed it used; re-running with
186 /// `--seed <hex>` reproduces that run byte-for-byte. Omitted, each run
187 /// draws a fresh random seed.
188 #[arg(long)]
189 seed: Option<String>,
190 },
191}
192
193/// The clap [`clap::Command`] tree for the `bynkc` CLI.
194pub fn command() -> clap::Command {
195 Cli::command()
196}
197
198fn styled_to_string(s: Option<&clap::builder::StyledStr>) -> String {
199 s.map(|s| s.to_string()).unwrap_or_default()
200}
201
202/// One usage token for an argument, e.g. `<INPUT>`, `[--check]`, `--output <OUTPUT>`.
203fn usage_token(arg: &clap::Arg) -> String {
204 let required = arg.is_required_set();
205 let is_flag = matches!(
206 arg.get_action(),
207 clap::ArgAction::SetTrue | clap::ArgAction::SetFalse
208 );
209 let value_name = arg
210 .get_value_names()
211 .and_then(|names| names.first().map(|n| n.to_string()))
212 .unwrap_or_else(|| arg.get_id().to_string().to_uppercase());
213
214 if arg.is_positional() {
215 if required {
216 format!("<{value_name}>")
217 } else {
218 format!("[{value_name}]")
219 }
220 } else {
221 let long = arg
222 .get_long()
223 .map(|l| format!("--{l}"))
224 .or_else(|| arg.get_short().map(|c| format!("-{c}")))
225 .unwrap_or_default();
226 if is_flag {
227 format!("[{long}]")
228 } else if required {
229 format!("{long} <{value_name}>")
230 } else {
231 format!("[{long} <{value_name}>]")
232 }
233 }
234}
235
236/// Render the CLI reference as a Markdown page, walking the clap command tree.
237pub fn render_markdown() -> String {
238 let root = command();
239 let mut out = String::new();
240
241 out.push_str("# CLI (`bynkc`)\n\n");
242 out.push_str(
243 "<!-- GENERATED FILE — do not edit by hand.\n \
244 Source: bynkc/src/cli.rs (`render_markdown`).\n \
245 Regenerate with: BYNK_BLESS=1 cargo test -p bynkc --test cli_reference -->\n\n",
246 );
247 let about = styled_to_string(root.get_about());
248 if !about.is_empty() {
249 out.push_str(&format!("{about}\n\n"));
250 }
251 out.push_str("Run `bynkc <command> --help` for the authoritative help text.\n");
252
253 out.push_str(
254 "\n## Exit codes and diagnostics\n\n\
255 A diagnostic's **severity** decides whether it fails a build (v0.89). \
256 An **`Error`** rejects the program: `bynkc compile`/`check` exit \
257 non-zero and produce no output. A **`Warning`** is surfaced but does \
258 **not** fail the build: these commands still **succeed (exit 0)** and \
259 emit their output, with warnings reported alongside. The build-failure \
260 gate counts error-severity diagnostics only. See the normative rule in \
261 the [specification](../spec/diagnostics.md) and the \
262 [diagnostic index](diagnostics.md) (warning-severity codes are marked \
263 *(warning)*).\n",
264 );
265
266 let mut subs: Vec<&clap::Command> = root
267 .get_subcommands()
268 .filter(|c| c.get_name() != "help")
269 .collect();
270 subs.sort_by_key(|c| c.get_name().to_string());
271
272 for sub in subs {
273 let name = sub.get_name();
274 out.push_str(&format!("\n## `bynkc {name}`\n\n"));
275 let about = styled_to_string(sub.get_about());
276 if !about.is_empty() {
277 out.push_str(&format!("{about}\n\n"));
278 }
279
280 // Usage line: positionals in declaration order, then options.
281 let mut usage = format!("bynkc {name}");
282 for arg in sub.get_arguments().filter(|a| a.is_positional()) {
283 usage.push(' ');
284 usage.push_str(&usage_token(arg));
285 }
286 for arg in sub.get_arguments().filter(|a| !a.is_positional()) {
287 usage.push(' ');
288 usage.push_str(&usage_token(arg));
289 }
290 out.push_str(&format!("```text\n{usage}\n```\n\n"));
291
292 let args: Vec<&clap::Arg> = sub.get_arguments().collect();
293 if !args.is_empty() {
294 out.push_str("| Argument | Required | Default | Description |\n");
295 out.push_str("|---|---|---|---|\n");
296 for arg in args {
297 let label = if arg.is_positional() {
298 format!("`{}`", arg.get_id().to_string().to_uppercase())
299 } else {
300 let long = arg
301 .get_long()
302 .map(|l| format!("`--{l}`"))
303 .unwrap_or_default();
304 match arg.get_short() {
305 Some(c) => format!("{long} (`-{c}`)"),
306 None => long,
307 }
308 };
309 let required = if arg.is_required_set() { "yes" } else { "no" };
310 let default = {
311 let defs: Vec<String> = arg
312 .get_default_values()
313 .iter()
314 .map(|v| v.to_string_lossy().to_string())
315 .collect();
316 if defs.is_empty() {
317 "—".to_string()
318 } else {
319 format!("`{}`", defs.join(", "))
320 }
321 };
322 let mut desc = styled_to_string(arg.get_help())
323 .replace('\n', " ")
324 .replace('|', "\\|");
325 // Boolean flags report `true`/`false` as possible values; that
326 // is noise, so only list choices for value-taking options.
327 let is_flag = matches!(
328 arg.get_action(),
329 clap::ArgAction::SetTrue | clap::ArgAction::SetFalse
330 );
331 let choices: Vec<String> = if is_flag {
332 Vec::new()
333 } else {
334 arg.get_possible_values()
335 .iter()
336 .map(|pv| pv.get_name().to_string())
337 .collect()
338 };
339 if !choices.is_empty() {
340 desc.push_str(&format!(" (one of: {})", choices.join(", ")));
341 }
342 out.push_str(&format!("| {label} | {required} | {default} | {desc} |\n"));
343 }
344 }
345 }
346
347 out
348}