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. A workspace root is
110 /// refused: compile each member.
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. A workspace root checks every
135 /// member in turn.
136 input: PathBuf,
137 /// Diagnostic output format. `rich` (default) is the ariadne
138 /// source-context rendering; `short` emits one terse
139 /// `path:line:col: severity[category]: message` line per diagnostic,
140 /// for tooling (the VS Code problem-matcher, CI, scripts). In both,
141 /// `path` is the file as you'd type it from the working directory
142 /// (the input you passed, joined with the file's place in it), the
143 /// same path `fmt` reports.
144 #[arg(long, value_enum, default_value = "rich")]
145 format: DiagFormat,
146 },
147 /// Format `.bynk` source files in place. Passing `-` reads from stdin
148 /// and writes to stdout.
149 ///
150 /// `--indent`, `--indent-width`, `--max-line-width` and
151 /// `--no-trailing-comma` override the canonical style for this run; with
152 /// none of them the output is the canonical formatting.
153 Fmt {
154 #[command(flatten)]
155 args: bynk_driver::FmtArgs,
156 },
157 /// Discover and run test declarations in a project. Compiles the project
158 /// (including all generated `tests/*.test.ts` modules), then invokes
159 /// Node.js on the aggregated runner script. Requires `tsc` and `node`
160 /// to be on PATH.
161 Test {
162 #[command(flatten)]
163 args: bynk_driver::test_runner::TestArgs,
164 },
165}
166
167/// The clap [`clap::Command`] tree for the `bynkc` CLI.
168pub fn command() -> clap::Command {
169 Cli::command()
170}
171
172fn styled_to_string(s: Option<&clap::builder::StyledStr>) -> String {
173 s.map(|s| s.to_string()).unwrap_or_default()
174}
175
176/// One usage token for an argument, e.g. `<INPUT>`, `[--check]`, `--output <OUTPUT>`.
177fn usage_token(arg: &clap::Arg) -> String {
178 let required = arg.is_required_set();
179 let is_flag = matches!(
180 arg.get_action(),
181 clap::ArgAction::SetTrue | clap::ArgAction::SetFalse
182 );
183 let value_name = arg
184 .get_value_names()
185 .and_then(|names| names.first().map(|n| n.to_string()))
186 .unwrap_or_else(|| arg.get_id().to_string().to_uppercase());
187
188 if arg.is_positional() {
189 if required {
190 format!("<{value_name}>")
191 } else {
192 format!("[{value_name}]")
193 }
194 } else {
195 let long = arg
196 .get_long()
197 .map(|l| format!("--{l}"))
198 .or_else(|| arg.get_short().map(|c| format!("-{c}")))
199 .unwrap_or_default();
200 if is_flag {
201 format!("[{long}]")
202 } else if required {
203 format!("{long} <{value_name}>")
204 } else {
205 format!("[{long} <{value_name}>]")
206 }
207 }
208}
209
210/// Render the CLI reference as a Markdown page, walking the clap command tree.
211pub fn render_markdown() -> String {
212 let root = command();
213 let mut out = String::new();
214
215 out.push_str("# CLI (`bynkc`)\n\n");
216 out.push_str(
217 "<!-- GENERATED FILE — do not edit by hand.\n \
218 Source: bynkc/src/cli.rs (`render_markdown`).\n \
219 Regenerate with: BYNK_BLESS=1 cargo test -p bynkc --test it cli_reference:: -->\n\n",
220 );
221 let about = styled_to_string(root.get_about());
222 if !about.is_empty() {
223 out.push_str(&format!("{about}\n\n"));
224 }
225 out.push_str("Run `bynkc <command> --help` for the authoritative help text.\n");
226
227 out.push_str(
228 "\n## Exit codes and diagnostics\n\n\
229 A diagnostic's **severity** decides whether it fails a build (v0.89). \
230 An **`Error`** rejects the program: `bynkc compile`/`check` exit \
231 non-zero and produce no output. A **`Warning`** is surfaced but does \
232 **not** fail the build: these commands still **succeed (exit 0)** and \
233 emit their output, with warnings reported alongside. The build-failure \
234 gate counts error-severity diagnostics only. See the normative rule in \
235 the [specification](../spec/diagnostics.md) and the \
236 [diagnostic index](diagnostics.md) (warning-severity codes are marked \
237 *(warning)*).\n",
238 );
239
240 let mut subs: Vec<&clap::Command> = root
241 .get_subcommands()
242 .filter(|c| c.get_name() != "help")
243 .collect();
244 subs.sort_by_key(|c| c.get_name().to_string());
245
246 for sub in subs {
247 let name = sub.get_name();
248 out.push_str(&format!("\n## `bynkc {name}`\n\n"));
249 let about = styled_to_string(sub.get_about());
250 if !about.is_empty() {
251 out.push_str(&format!("{about}\n\n"));
252 }
253
254 // Usage line: positionals in declaration order, then options.
255 let mut usage = format!("bynkc {name}");
256 for arg in sub.get_arguments().filter(|a| a.is_positional()) {
257 usage.push(' ');
258 usage.push_str(&usage_token(arg));
259 }
260 for arg in sub.get_arguments().filter(|a| !a.is_positional()) {
261 usage.push(' ');
262 usage.push_str(&usage_token(arg));
263 }
264 out.push_str(&format!("```text\n{usage}\n```\n\n"));
265
266 let args: Vec<&clap::Arg> = sub.get_arguments().collect();
267 if !args.is_empty() {
268 out.push_str("| Argument | Required | Default | Description |\n");
269 out.push_str("|---|---|---|---|\n");
270 for arg in args {
271 let label = if arg.is_positional() {
272 format!("`{}`", arg.get_id().to_string().to_uppercase())
273 } else {
274 let long = arg
275 .get_long()
276 .map(|l| format!("`--{l}`"))
277 .unwrap_or_default();
278 match arg.get_short() {
279 Some(c) => format!("{long} (`-{c}`)"),
280 None => long,
281 }
282 };
283 let required = if arg.is_required_set() { "yes" } else { "no" };
284 let default = {
285 let defs: Vec<String> = arg
286 .get_default_values()
287 .iter()
288 .map(|v| v.to_string_lossy().to_string())
289 .collect();
290 if defs.is_empty() {
291 "—".to_string()
292 } else {
293 format!("`{}`", defs.join(", "))
294 }
295 };
296 let mut desc = styled_to_string(arg.get_help())
297 .replace('\n', " ")
298 .replace('|', "\\|");
299 // Boolean flags report `true`/`false` as possible values; that
300 // is noise, so only list choices for value-taking options.
301 let is_flag = matches!(
302 arg.get_action(),
303 clap::ArgAction::SetTrue | clap::ArgAction::SetFalse
304 );
305 let choices: Vec<String> = if is_flag {
306 Vec::new()
307 } else {
308 arg.get_possible_values()
309 .iter()
310 .map(|pv| pv.get_name().to_string())
311 .collect()
312 };
313 if !choices.is_empty() {
314 desc.push_str(&format!(" (one of: {})", choices.join(", ")));
315 }
316 out.push_str(&format!("| {label} | {required} | {default} | {desc} |\n"));
317 }
318 }
319 }
320
321 out
322}