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, and the `test` subcommand's flags —
34/// [`bynk_driver::test_runner::TestFormat`]/[`TestArgs`], re-exported so
35/// existing `bynkc::cli::TestFormat` paths resolve unchanged (Wave 5 §5.4:
36/// the `test` subcommand's contract, findings #40/#72, is now shared with
37/// `bynk test` rather than a per-command near-duplicate).
38pub use bynk_driver::test_runner::{TestArgs, TestFormat};
39
40/// The `fmt` subcommand's flags, shared with `bynk fmt` the same way (#968):
41/// one `#[derive(Args)]` struct both CLIs flatten, rather than two copies to
42/// keep in step as formatting options are added.
43pub use bynk_driver::{FmtArgs, IndentKind};
44
45#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
46pub enum CliTarget {
47 /// Single-bundle output (the default). Cross-context calls compile to
48 /// direct function invocation.
49 Bundle,
50 /// One Cloudflare Worker per context. Cross-context calls go over
51 /// Service Bindings using a JSON wire format.
52 Workers,
53}
54
55impl From<CliTarget> for BuildTarget {
56 fn from(t: CliTarget) -> Self {
57 match t {
58 CliTarget::Bundle => BuildTarget::Bundle,
59 CliTarget::Workers => BuildTarget::Workers,
60 }
61 }
62}
63
64/// v0.108 (in-browser track, slice 1): the emitted artefact language. `ts` (the
65/// default and primary output) writes the typed TypeScript modules; `js` writes
66/// the same modules with their types stripped — an *emit-then-strip* JavaScript
67/// artefact (ADR 0137) runnable with no `tsc` in the loop. Orthogonal to
68/// `--target` (topology) and `--platform` (binding).
69#[derive(Copy, Clone, Debug, PartialEq, Eq, Default, ValueEnum)]
70pub enum EmitFormat {
71 /// TypeScript modules (the default, primary artefact).
72 #[default]
73 Ts,
74 /// JavaScript modules, types stripped (no `tsc` dependency).
75 Js,
76}
77
78/// v0.17: the deploy platform that selects the `bynk` surface binding. Distinct
79/// from [`CliTarget`] (the emit topology). v0.18 adds `node`.
80#[derive(Copy, Clone, Debug, PartialEq, Eq, Default, ValueEnum)]
81pub enum CliPlatform {
82 /// Cloudflare Workers runtime (the default).
83 #[default]
84 Cloudflare,
85 /// Node.js (≥ [`NODE_MAJOR_FLOOR`](crate::NODE_MAJOR_FLOOR)) runtime (v0.18).
86 Node,
87 /// The browser — the `bynk` surface over Web APIs, for the in-browser
88 /// REPL/playground (v0.108). `Bundle` topology only; `Fetch`/`Secrets` are
89 /// withheld (see ADR 0138).
90 Browser,
91}
92
93impl From<CliPlatform> for crate::Platform {
94 fn from(p: CliPlatform) -> Self {
95 match p {
96 CliPlatform::Cloudflare => crate::Platform::Cloudflare,
97 CliPlatform::Node => crate::Platform::Node,
98 CliPlatform::Browser => crate::Platform::Browser,
99 }
100 }
101}
102
103#[derive(Subcommand, Debug)]
104pub enum Command {
105 /// Compile a `.bynk` file (single-file commons) to a TypeScript file,
106 /// or a directory project to a tree of TypeScript files mirroring the
107 /// source layout.
108 Compile {
109 /// Input `.bynk` file, or directory project root.
110 input: PathBuf,
111 /// Output `.ts` file (for single-file input) or output root
112 /// directory (for project input).
113 #[arg(short, long)]
114 output: PathBuf,
115 /// Build target. `bundle` (default) produces a single deployment
116 /// unit; `workers` produces one Cloudflare Worker per context with
117 /// Service Binding plumbing (v0.8).
118 #[arg(long, value_enum, default_value = "bundle")]
119 target: CliTarget,
120 /// Deploy platform selecting the `bynk` surface binding (v0.17). A new
121 /// axis, distinct from `--target`: `cloudflare` (default), `node`, or
122 /// `browser` (the in-browser playground binding; `Bundle` topology only).
123 #[arg(long, value_enum, default_value = "cloudflare")]
124 platform: CliPlatform,
125 /// Artefact language (v0.108). `ts` (default) writes typed TypeScript;
126 /// `js` writes the same modules with types stripped — a JavaScript
127 /// artefact that runs with no `tsc` in the loop (ADR 0137).
128 #[arg(long, value_enum, default_value = "ts")]
129 emit: EmitFormat,
130 },
131 /// Type-check a `.bynk` file or project without writing output.
132 Check {
133 /// Input `.bynk` file or project root.
134 input: PathBuf,
135 /// Diagnostic output format. `rich` (default) is the ariadne
136 /// source-context rendering; `short` emits one terse
137 /// `path:line:col: severity[category]: message` line per diagnostic,
138 /// for tooling (the VS Code problem-matcher, CI, scripts). In both,
139 /// `path` is the file as you'd type it from the working directory
140 /// (the input you passed, joined with the file's place in it), the
141 /// same path `fmt` reports.
142 #[arg(long, value_enum, default_value = "rich")]
143 format: DiagFormat,
144 },
145 /// Format `.bynk` source files in place. Passing `-` reads from stdin
146 /// and writes to stdout.
147 ///
148 /// `--indent`, `--indent-width`, `--max-line-width` and
149 /// `--no-trailing-comma` override the canonical style for this run; with
150 /// none of them the output is the canonical formatting.
151 Fmt {
152 #[command(flatten)]
153 args: bynk_driver::FmtArgs,
154 },
155 /// Discover and run test declarations in a project. Compiles the project
156 /// (including all generated `tests/*.test.ts` modules), then invokes
157 /// Node.js on the aggregated runner script. Requires `tsc` and `node`
158 /// to be on PATH.
159 Test {
160 #[command(flatten)]
161 args: bynk_driver::test_runner::TestArgs,
162 },
163}
164
165/// The clap [`clap::Command`] tree for the `bynkc` CLI.
166pub fn command() -> clap::Command {
167 Cli::command()
168}
169
170fn styled_to_string(s: Option<&clap::builder::StyledStr>) -> String {
171 s.map(|s| s.to_string()).unwrap_or_default()
172}
173
174/// One usage token for an argument, e.g. `<INPUT>`, `[--check]`, `--output <OUTPUT>`.
175fn usage_token(arg: &clap::Arg) -> String {
176 let required = arg.is_required_set();
177 let is_flag = matches!(
178 arg.get_action(),
179 clap::ArgAction::SetTrue | clap::ArgAction::SetFalse
180 );
181 let value_name = arg
182 .get_value_names()
183 .and_then(|names| names.first().map(|n| n.to_string()))
184 .unwrap_or_else(|| arg.get_id().to_string().to_uppercase());
185
186 if arg.is_positional() {
187 if required {
188 format!("<{value_name}>")
189 } else {
190 format!("[{value_name}]")
191 }
192 } else {
193 let long = arg
194 .get_long()
195 .map(|l| format!("--{l}"))
196 .or_else(|| arg.get_short().map(|c| format!("-{c}")))
197 .unwrap_or_default();
198 if is_flag {
199 format!("[{long}]")
200 } else if required {
201 format!("{long} <{value_name}>")
202 } else {
203 format!("[{long} <{value_name}>]")
204 }
205 }
206}
207
208/// Render the CLI reference as a Markdown page, walking the clap command tree.
209pub fn render_markdown() -> String {
210 let root = command();
211 let mut out = String::new();
212
213 out.push_str("# CLI (`bynkc`)\n\n");
214 out.push_str(
215 "<!-- GENERATED FILE — do not edit by hand.\n \
216 Source: bynkc/src/cli.rs (`render_markdown`).\n \
217 Regenerate with: BYNK_BLESS=1 cargo test -p bynkc --test cli_reference -->\n\n",
218 );
219 let about = styled_to_string(root.get_about());
220 if !about.is_empty() {
221 out.push_str(&format!("{about}\n\n"));
222 }
223 out.push_str("Run `bynkc <command> --help` for the authoritative help text.\n");
224
225 out.push_str(
226 "\n## Exit codes and diagnostics\n\n\
227 A diagnostic's **severity** decides whether it fails a build (v0.89). \
228 An **`Error`** rejects the program: `bynkc compile`/`check` exit \
229 non-zero and produce no output. A **`Warning`** is surfaced but does \
230 **not** fail the build: these commands still **succeed (exit 0)** and \
231 emit their output, with warnings reported alongside. The build-failure \
232 gate counts error-severity diagnostics only. See the normative rule in \
233 the [specification](../spec/diagnostics.md) and the \
234 [diagnostic index](diagnostics.md) (warning-severity codes are marked \
235 *(warning)*).\n",
236 );
237
238 let mut subs: Vec<&clap::Command> = root
239 .get_subcommands()
240 .filter(|c| c.get_name() != "help")
241 .collect();
242 subs.sort_by_key(|c| c.get_name().to_string());
243
244 for sub in subs {
245 let name = sub.get_name();
246 out.push_str(&format!("\n## `bynkc {name}`\n\n"));
247 let about = styled_to_string(sub.get_about());
248 if !about.is_empty() {
249 out.push_str(&format!("{about}\n\n"));
250 }
251
252 // Usage line: positionals in declaration order, then options.
253 let mut usage = format!("bynkc {name}");
254 for arg in sub.get_arguments().filter(|a| a.is_positional()) {
255 usage.push(' ');
256 usage.push_str(&usage_token(arg));
257 }
258 for arg in sub.get_arguments().filter(|a| !a.is_positional()) {
259 usage.push(' ');
260 usage.push_str(&usage_token(arg));
261 }
262 out.push_str(&format!("```text\n{usage}\n```\n\n"));
263
264 let args: Vec<&clap::Arg> = sub.get_arguments().collect();
265 if !args.is_empty() {
266 out.push_str("| Argument | Required | Default | Description |\n");
267 out.push_str("|---|---|---|---|\n");
268 for arg in args {
269 let label = if arg.is_positional() {
270 format!("`{}`", arg.get_id().to_string().to_uppercase())
271 } else {
272 let long = arg
273 .get_long()
274 .map(|l| format!("`--{l}`"))
275 .unwrap_or_default();
276 match arg.get_short() {
277 Some(c) => format!("{long} (`-{c}`)"),
278 None => long,
279 }
280 };
281 let required = if arg.is_required_set() { "yes" } else { "no" };
282 let default = {
283 let defs: Vec<String> = arg
284 .get_default_values()
285 .iter()
286 .map(|v| v.to_string_lossy().to_string())
287 .collect();
288 if defs.is_empty() {
289 "—".to_string()
290 } else {
291 format!("`{}`", defs.join(", "))
292 }
293 };
294 let mut desc = styled_to_string(arg.get_help())
295 .replace('\n', " ")
296 .replace('|', "\\|");
297 // Boolean flags report `true`/`false` as possible values; that
298 // is noise, so only list choices for value-taking options.
299 let is_flag = matches!(
300 arg.get_action(),
301 clap::ArgAction::SetTrue | clap::ArgAction::SetFalse
302 );
303 let choices: Vec<String> = if is_flag {
304 Vec::new()
305 } else {
306 arg.get_possible_values()
307 .iter()
308 .map(|pv| pv.get_name().to_string())
309 .collect()
310 };
311 if !choices.is_empty() {
312 desc.push_str(&format!(" (one of: {})", choices.join(", ")));
313 }
314 out.push_str(&format!("| {label} | {required} | {default} | {desc} |\n"));
315 }
316 }
317 }
318
319 out
320}