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, 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}