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