Skip to main content

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}