Skip to main content

stryke/
cli.rs

1use std::fs::File;
2use std::io::{self, BufReader, IsTerminal, Read as IoRead, Write};
3use std::path::{Path, PathBuf};
4use std::process;
5use std::sync::Mutex;
6
7use clap::Parser;
8use rand::Rng;
9use rayon::prelude::*;
10
11use crate::ast::Program;
12use crate::error::{ErrorKind, StrykeError};
13use crate::perl_fs::{
14    decode_utf8_or_latin1, read_file_text_perl_compat, read_line_perl_compat_with_sep,
15    read_logical_line_perl_compat_with_sep,
16};
17use crate::vm_helper::VMHelper;
18
19use crate::repl;
20
21/// stryke — A highly parallel Perl 5 interpreter written in Rust
22#[derive(Parser, Debug, Default)]
23#[command(name = "stryke", version, about, long_about = None)]
24#[command(disable_version_flag = true, disable_help_flag = true)]
25#[command(override_usage = "stryke [switches] [--] [programfile] [arguments]")]
26pub struct Cli {
27    /// Specify record separator (\0 if no argument); -0777 for slurp mode
28    #[arg(short = '0', value_name = "OCTAL")]
29    input_separator: Option<Option<String>>,
30
31    /// Autosplit mode with -n or -p (splits $_ into @F)
32    #[arg(short = 'a')]
33    auto_split: bool,
34
35    /// Enables the listed Unicode features
36    #[arg(short = 'C', value_name = "NUMBER/LIST")]
37    unicode_features: Option<Option<String>>,
38
39    /// Check syntax only (parse; does not compile or run)
40    #[arg(short = 'c')]
41    check_only: bool,
42
43    /// Parse and compile without executing (bytecode compile check; alias `--check`)
44    #[arg(long = "lint", alias = "check")]
45    lint: bool,
46
47    /// Print bytecode disassembly to stderr before VM execution (alias `--disassemble`)
48    #[arg(long = "disasm", alias = "disassemble")]
49    disasm: bool,
50
51    /// Dump the parsed abstract syntax tree as JSON to stdout and exit (no execution)
52    #[arg(long = "ast")]
53    dump_ast: bool,
54
55    /// Print the lexer token stream for the program and exit.
56    #[arg(long = "dump-tokens")]
57    dump_tokens: bool,
58
59    /// Print the compiled fusevm bytecode ops for the program and exit.
60    #[arg(long = "dump-bytecode")]
61    dump_bytecode: bool,
62
63    /// Run the program, then report which fusevm execution tier took its chunk.
64    #[arg(long = "tiers")]
65    tiers: bool,
66
67    /// Pretty-print parsed Perl to stdout and exit (no execution)
68    #[arg(long = "fmt")]
69    format_source: bool,
70
71    /// Transpile the input file from zsh to stryke on stdout and exit (no execution)
72    #[arg(long = "from-zsh")]
73    from_zsh: bool,
74
75    /// Wall-clock profile: per-line + per-sub timings on stderr (VM: opcode-level lines; JIT off)
76    #[arg(long = "profile")]
77    profile: bool,
78
79    /// Flamegraph: colored terminal bars (TTY) or SVG to stdout (piped: stryke --flame x.stk > flame.svg)
80    #[arg(long = "flame")]
81    flame: bool,
82
83    /// Disable Cranelift JIT for bytecode VM (opcode interpreter only)
84    #[arg(long = "no-jit")]
85    no_jit: bool,
86
87    /// Print expanded hint for an error code (e.g. E0001) and exit
88    #[arg(long = "explain", value_name = "CODE")]
89    explain: Option<String>,
90
91    /// Run program under debugger or module Devel::MOD
92    #[arg(short = 'd', value_name = "MOD")]
93    debugger: Option<Option<String>>,
94
95    /// Set debugging flags (argument is a bit mask or alphabets)
96    #[arg(short = 'D', value_name = "FLAGS")]
97    debug_flags: Option<Option<String>>,
98
99    /// One line of program (several -e's allowed, omit programfile)
100    #[arg(short = 'e')]
101    execute: Vec<String>,
102
103    /// Like -e, but enables all optional features
104    #[arg(short = 'E')]
105    execute_features: Vec<String>,
106
107    /// Don't do $sitelib/sitecustomize.pl at startup
108    #[arg(short = 'f')]
109    no_sitecustomize: bool,
110
111    /// Split() pattern for -a switch (//'s are optional)
112    #[arg(short = 'F', value_name = "PATTERN")]
113    field_separator: Option<String>,
114
115    /// Read all input in one go (slurp), alias for -0777
116    #[arg(short = 'g')]
117    slurp: bool,
118
119    /// Edit <> files in place (makes backup if extension supplied)
120    #[arg(short = 'i', value_name = "EXTENSION")]
121    inplace: Option<Option<String>>,
122
123    /// Specify @INC/#include directory (several -I's allowed)
124    #[arg(short = 'I', value_name = "DIRECTORY")]
125    include: Vec<String>,
126
127    /// Enable line ending processing, specifies line terminator
128    #[arg(short = 'l', value_name = "OCTNUM")]
129    line_ending: Option<Option<String>>,
130
131    /// Execute "use module..." before executing program
132    #[arg(short = 'M', value_name = "MODULE")]
133    use_module: Vec<String>,
134
135    /// Execute "use module ()" before executing program (no import)
136    #[arg(short = 'm', value_name = "MODULE")]
137    use_module_no_import: Vec<String>,
138
139    /// Assume "while (<>) { ... }" loop around program
140    #[arg(short = 'n')]
141    line_mode: bool,
142
143    /// Assume loop like -n but print line also, like sed
144    #[arg(short = 'p')]
145    print_mode: bool,
146
147    /// Enable rudimentary parsing for switches after programfile
148    #[arg(short = 's')]
149    switch_parsing: bool,
150
151    /// Look for programfile using PATH environment variable
152    #[arg(short = 'S')]
153    path_lookup: bool,
154
155    /// Enable tainting warnings
156    #[arg(short = 't')]
157    taint_warn: bool,
158
159    /// Enable tainting checks
160    #[arg(short = 'T')]
161    taint_check: bool,
162
163    /// Dump core after parsing program
164    #[arg(short = 'u')]
165    dump_core: bool,
166
167    /// Allow unsafe operations
168    #[arg(short = 'U')]
169    unsafe_ops: bool,
170
171    /// Print version, patchlevel and license
172    #[arg(short = 'v')]
173    show_version: bool,
174
175    /// Print configuration summary (or a single Config.pm variable)
176    #[arg(short = 'V', value_name = "CONFIGVAR")]
177    show_config: Option<Option<String>>,
178
179    /// Enable many useful warnings
180    #[arg(short = 'w')]
181    warnings: bool,
182
183    /// Enable all warnings
184    #[arg(short = 'W')]
185    all_warnings: bool,
186
187    /// Ignore text before #!perl line (optionally cd to directory)
188    #[arg(short = 'x', value_name = "DIRECTORY")]
189    extract: Option<Option<String>>,
190
191    /// Disable all warnings
192    #[arg(short = 'X')]
193    no_warnings: bool,
194
195    /// Print help
196    #[arg(short = 'h', long = "help")]
197    help: bool,
198
199    /// Number of threads for parallel operations (stryke extension)
200    #[arg(short = 'j', long = "threads", value_name = "N")]
201    threads: Option<usize>,
202
203    /// Perl 5 strict-compatibility mode: disable all stryke extensions
204    #[arg(long = "compat")]
205    compat: bool,
206
207    /// Mandatory static typing: require types on all params, return types, and
208    /// variable declarations (Kotlin-style); abort on type mismatches
209    #[arg(long = "static")]
210    static_typing: bool,
211
212    /// No Perl interop: reject Perl-isms (sub/say/reverse), force idiomatic stryke
213    #[arg(long = "no-interop")]
214    no_interop: bool,
215
216    /// Force argument to be treated as a script file (skip code detection)
217    #[arg(long = "script")]
218    force_script: bool,
219
220    /// Script file to execute
221    #[arg(value_name = "SCRIPT")]
222    script: Option<String>,
223
224    /// Arguments passed to the script (@ARGV)
225    #[arg(value_name = "ARGS", trailing_var_arg = true)]
226    args: Vec<String>,
227}
228
229/// Expand Perl-style bundled short switches (`-lane` → `-l -a -n -e`, `-0777` unchanged) before
230/// clap parses. Stock clap treats `-lane` as `-l` with value `ane`.
231fn expand_perl_bundled_argv(args: Vec<String>) -> Vec<String> {
232    if args.is_empty() {
233        return args;
234    }
235    let mut out = vec![args[0].clone()];
236    let mut seen_dd = false;
237    for arg in args.into_iter().skip(1) {
238        if seen_dd {
239            out.push(arg);
240            continue;
241        }
242        if arg == "--" {
243            seen_dd = true;
244            out.push(arg);
245            continue;
246        }
247        match expand_perl_bundled_token(&arg) {
248            Some(parts) => out.extend(parts),
249            None => out.push(arg),
250        }
251    }
252    out
253}
254
255/// Perl documents `-help` / `-version` as aliases; bundling would mis-parse them as `-h`+`-e`+….
256fn expand_perl_bundled_token(arg: &str) -> Option<Vec<String>> {
257    match arg {
258        "-help" | "--help" => return Some(vec!["-h".to_string()]),
259        "-version" | "--version" => return Some(vec!["-v".to_string()]),
260        _ => {}
261    }
262    if arg == "-" || !arg.starts_with('-') || arg.starts_with("--") {
263        return None;
264    }
265    let s = arg.strip_prefix('-')?;
266    if s.is_empty() || s.len() == 1 {
267        return None;
268    }
269    // Operators like `->>`, `->`, `-~>` start with non-letter after `-`; not bundled flags.
270    if s.starts_with('>') || s.starts_with('~') {
271        return None;
272    }
273    // `-0` / `-0777` — record separator; do not split into `-0` `-7` …
274    if let Some(rest) = s.strip_prefix('0') {
275        let rest_ok = rest.chars().all(|c| matches!(c, '0'..='7'));
276        if rest_ok {
277            return None;
278        }
279    }
280    let mut out = Vec::new();
281    let b = s.as_bytes();
282    let mut i = 0usize;
283    while i < b.len() {
284        match b[i] {
285            b'0' if i == 0 => {
286                let mut j = i + 1;
287                while j < b.len() && matches!(b[j], b'0'..=b'7') {
288                    j += 1;
289                }
290                out.push("-0".to_string());
291                if j > i + 1 {
292                    out.push(s[i + 1..j].to_string());
293                }
294                i = j;
295            }
296            b'e' | b'E' => {
297                let flag = if b[i] == b'e' { "-e" } else { "-E" };
298                out.push(flag.to_string());
299                if i + 1 < b.len() {
300                    out.push(s[i + 1..].to_string());
301                }
302                return Some(out);
303            }
304            b'l' => {
305                out.push("-l".to_string());
306                i += 1;
307                let start = i;
308                while i < b.len() && matches!(b[i], b'0'..=b'7') {
309                    i += 1;
310                }
311                if i > start {
312                    out.push(s[start..i].to_string());
313                }
314            }
315            // Flags that consume the rest of the token as their value:
316            //   -F pattern  — split pattern for -a
317            //   -M module   — use module
318            //   -m module   — use module ()
319            //   -I dir      — @INC directory
320            //   -V:var      — config variable (Perl: `perl -V:version`)
321            //   -d:mod      — debugger module
322            //   -D flags    — debug flags
323            //   -x dir      — ignore text before #!perl
324            //   -C flags    — unicode features
325            b'F' | b'M' | b'm' | b'I' | b'd' | b'D' | b'x' | b'C' => {
326                let ch = b[i] as char;
327                out.push(format!("-{ch}"));
328                i += 1;
329                if i < b.len() {
330                    out.push(s[i..].to_string());
331                }
332                return Some(out);
333            }
334            b'V' => {
335                // `-V:var` → `-V` `:var`; `-V` alone → `-V`
336                out.push("-V".to_string());
337                i += 1;
338                if i < b.len() {
339                    // Perl's `-V:version` passes `:version` but the handler expects just `version`.
340                    let rest = &s[i..];
341                    let rest = rest.strip_prefix(':').unwrap_or(rest);
342                    out.push(rest.to_string());
343                }
344                return Some(out);
345            }
346            b'i' => {
347                out.push("-i".to_string());
348                i += 1;
349                if i < b.len() && matches!(b[i], b'e' | b'E') {
350                    continue;
351                }
352                if i < b.len() && b[i] == b'.' {
353                    let start = i;
354                    while i < b.len() && !matches!(b[i], b'e' | b'E') {
355                        i += 1;
356                    }
357                    out.push(s[start..i].to_string());
358                }
359            }
360            _ => {
361                out.push(format!("-{}", b[i] as char));
362                i += 1;
363            }
364        }
365    }
366    Some(out)
367}
368
369fn print_cyberpunk_help() {
370    let version = env!("CARGO_PKG_VERSION");
371    // Was `env!("CARGO_BIN_NAME")`, which a library has no such thing as. The
372    // name to print is the one the user typed — `s -h` should say `s` — so it
373    // comes from argv[0] now. See [`invoked_as`].
374    let bin = invoked_as();
375    let bin = bin.as_str();
376
377    // ANSI color codes
378    const C: &str = "\x1b[36m"; // cyan
379    const M: &str = "\x1b[35m"; // magenta
380    const Y: &str = "\x1b[33m"; // yellow
381    const G: &str = "\x1b[32m"; // green
382    const N: &str = "\x1b[0m"; // reset
383
384    repl::print_cyberpunk_banner();
385    println!();
386    println!();
387    println!("A highly parallel Perl 5 interpreter written in Rust");
388    println!();
389    println!("{Y}  USAGE:{N} {bin} 'CODE'                     {G}//{N} -e is optional");
390    println!("{Y}        {N} {bin} [switches] [--] [programfile] [arguments]");
391    println!();
392    println!("{C}  ── EXECUTION ──────────────────────────────────────────{N}");
393    println!("  'CODE'                 {G}//{N} Inline code — no -e needed if arg looks like code");
394    println!("  -e CODE                {G}//{N} Explicit inline (required with -n/-p/-l/-a)");
395    println!("  -E CODE                {G}//{N} Like -e, but enables all optional features");
396    println!("  BUILTIN [ARGS]         {G}//{N} Call builtin fn directly: stryke pin, stryke basename /a/b");
397    println!(
398        "  --script               {G}//{N} Force script lookup when name conflicts with builtin"
399    );
400    println!("  -c                     {G}//{N} Check syntax only (parse; no compile/run)");
401    println!("  --lint / --check       {G}//{N} Parse + compile bytecode without running");
402    println!(
403        "  --disasm / --disassemble {G}//{N} Print bytecode disassembly to stderr before VM run"
404    );
405    println!("  --ast                  {G}//{N} Dump parsed AST as JSON and exit (no execution)");
406    println!(
407        "  --dump-tokens          {G}//{N} Print the lexer token stream and exit (no execution)"
408    );
409    println!(
410        "  --dump-bytecode        {G}//{N} Print the compiled fusevm bytecode ops and exit (no execution)"
411    );
412    println!("  --fmt                  {G}//{N} Pretty-print parsed Perl to stdout and exit");
413    println!(
414        "  --from-zsh             {G}//{N} Transpile a zsh script to stryke on stdout and exit"
415    );
416    println!(
417        "  --explain CODE         {G}//{N} Print expanded hint for an error code (e.g. E0001) and exit"
418    );
419    println!(
420        "  --profile              {G}//{N} Wall-clock profile stderr (VM op lines; flamegraph-ready)"
421    );
422    println!(
423        "  --flame                {G}//{N} Flamegraph: terminal bars (TTY) or SVG (piped to file)"
424    );
425    println!("  --no-jit               {G}//{N} Disable Cranelift JIT (bytecode interpreter only)");
426    println!(
427        "  --compat               {G}//{N} Perl 5 strict-compat: disable all stryke extensions"
428    );
429    println!(
430        "  --static               {G}//{N} Mandatory static typing: require types everywhere (Kotlin-style)"
431    );
432    println!(
433        "  --no-interop           {G}//{N} Reject Perl-isms (sub/say/reverse, $a/$b), force idiomatic stryke ($_0/$_1)"
434    );
435    println!("  -d[t][:MOD]            {G}//{N} Run program under debugger or module Devel::MOD");
436    println!("  -D[number/letters]     {G}//{N} Set debugging flags");
437    println!("  -u                     {G}//{N} Dump core after parsing program");
438    println!("{C}  ── INPUT PROCESSING ─────────────────────────────────{N}");
439    println!("  -n                     {G}//{N} Assume \"while (<>) {{...}}\" loop around program");
440    println!("  -p                     {G}//{N} Like -n but print line also, like sed");
441    println!("  -a                     {G}//{N} Autosplit mode (splits $_ into @F)");
442    println!("  -F/pattern/            {G}//{N} split() pattern for -a switch");
443    println!("  -l[octnum]             {G}//{N} Enable line ending processing");
444    println!("  -0[octal]              {G}//{N} Specify record separator (\\0 if no arg)");
445    println!("  -g                     {G}//{N} Slurp all input at once (alias for -0777)");
446    println!("  -i[extension]          {G}//{N} Edit <> files in place (backup if ext supplied; multiple files in parallel)");
447    println!("{C}  ── MODULES & PATHS ──────────────────────────────────{N}");
448    println!("  -M MODULE              {G}//{N} Execute \"use module...\" before program");
449    println!(
450        "  -m MODULE              {G}//{N} Execute \"use module ()\" before program (no import)"
451    );
452    println!("  -I DIRECTORY           {G}//{N} Specify @INC directory (several allowed)");
453    println!("  -f                     {G}//{N} Don't do $sitelib/sitecustomize.pl at startup");
454    println!("  -S                     {G}//{N} Look for programfile using PATH");
455    println!("  -x[directory]          {G}//{N} Ignore text before #!perl line");
456    println!("{C}  ── UNICODE & SAFETY ─────────────────────────────────{N}");
457    println!("  -C[number/list]        {G}//{N} Enable listed Unicode features");
458    println!("  -t                     {G}//{N} Enable tainting warnings");
459    println!("  -T                     {G}//{N} Enable tainting checks");
460    println!("  -U                     {G}//{N} Allow unsafe operations");
461    println!("  -s                     {G}//{N} Enable switch parsing for programfile args");
462    println!("{C}  ── WARNINGS ─────────────────────────────────────────{N}");
463    println!("  -w                     {G}//{N} Enable many useful warnings");
464    println!("  -W                     {G}//{N} Enable all warnings");
465    println!("  -X                     {G}//{N} Disable all warnings");
466    println!("{C}  ── INFO ─────────────────────────────────────────────{N}");
467    println!("  -v                     {G}//{N} Print version, patchlevel and license");
468    println!("  -V[:configvar]         {G}//{N} Print configuration summary");
469    println!("  -h, --help             {G}//{N} Print help");
470    println!("{C}  ── TOOLCHAIN ─────────────────────────────────────────{N}");
471    println!(
472        "  --lsp                  {G}//{N} Language Server (JSON-RPC on stdio); must be the only arg after {bin}"
473    );
474    println!(
475        "  build SCRIPT [-o OUT]  {G}//{N} AOT: copy this binary with SCRIPT embedded (standalone exe)"
476    );
477    println!("  docs [TOPIC]           {G}//{N} Built-in docs (stryke docs pmap, stryke docs |>, stryke docs)");
478    println!(
479        "  serve [PORT] [SCRIPT]  {G}//{N} HTTP server (stryke serve, stryke serve 8080 app.stk)"
480    );
481    println!("  fmt [-i] FILE...       {G}//{N} Format source files (stryke fmt -i .)");
482    println!(
483        "  minify [-i] FILE...    {G}//{N} Strip comments / POD / blank lines, collapse to one line w/ `;` (stryke minify app.stk)"
484    );
485    println!(
486        "  bench [FILE|DIR]       {G}//{N} Run benchmarks from bench/ or benches/ (stryke bench)"
487    );
488    println!(
489        "  init [NAME]            {G}//{N} Scaffold project in cwd (stryke.toml + lib/, t/, benches/)"
490    );
491    println!(
492        "  new NAME               {G}//{N} Scaffold a new project at ./NAME/ (same layout as init)"
493    );
494    println!(
495        "  install [--offline]    {G}//{N} Resolve manifest deps, populate stryke.lock + ~/.stryke/store"
496    );
497    println!(
498        "  add NAME[@VER] [...]   {G}//{N} Add a dep to stryke.toml; flags: --dev --group=N --path=DIR --features=A,B"
499    );
500    println!("  remove NAME            {G}//{N} Drop a dep from stryke.toml; reruns install");
501    println!("  tree                   {G}//{N} Print resolved dep graph from stryke.lock");
502    println!("  info NAME              {G}//{N} Show lockfile entry + store path for a dep");
503    println!(
504        "  pkg <subcommand>       {G}//{N} Dispatcher for the package commands above (init/new/install/add/remove/tree/info)"
505    );
506    println!(
507        "  repl [--load FILE]     {G}//{N} Interactive REPL with optional pre-load (stryke repl)"
508    );
509    println!(
510        "  --remote-worker        {G}//{N} Persistent cluster worker (stdio); only arg after {bin}"
511    );
512    println!(
513        "  --remote-worker-v1     {G}//{N} Legacy one-shot worker (stdio); only arg after {bin}"
514    );
515    println!("{C}  ── PARALLEL EXTENSIONS (stryke) ─────────────────────{N}");
516    println!("  -j N                   {G}//{N} Set number of parallel threads (rayon)");
517    println!(
518        "  pmap  {{BLOCK}} @list [, progress => EXPR] {G}//{N} Parallel map; optional stderr progress bar"
519    );
520    println!(
521        "  pmap_chunked N {{BLOCK}} @list [, progress => EXPR] {G}//{N} Parallel map in batches of N items per thread"
522    );
523    println!(
524        "  pcache {{BLOCK}} @list [, progress => EXPR] {G}//{N} Parallel memoize (key = stringified topic)"
525    );
526    println!(
527        "  par_lines PATH, CODE [, progress => EXPR] {G}//{N} mmap + parallel line scan (interpreter)"
528    );
529    println!(
530        "  par_walk PATH, CODE [, progress => EXPR] {G}//{N} parallel recursive dir walk; topic is each path"
531    );
532    println!(
533        "  par_sed PATTERN, REPLACEMENT, FILES... [, progress => EXPR] {G}//{N} parallel in-place regex replace per file (g)"
534    );
535    println!(
536        "  pipeline @list ->filter/map/take/collect {G}//{N} Lazy iterator (runs on collect); chain ->pmap/pgrep/pfor/pmap_chunked/psort/pcache/preduce/… like top-level p*"
537    );
538    println!(
539        "  par_pipeline @list same chain; filter/map parallel on collect (order kept); par_pipeline(source=>…,stages=>…,workers=>…) channel stages"
540    );
541    println!(
542        "  async {{BLOCK}}           {G}//{N} Run block on a worker thread; returns a task handle"
543    );
544    println!("  spawn {{BLOCK}}           {G}//{N} Same as async (Rust-style); join with await");
545    println!("  await EXPR                {G}//{N} Join async task or pass through non-task value");
546    println!(
547        "  pgrep {{BLOCK}} @list [, progress => EXPR] {G}//{N} Parallel grep across all cores"
548    );
549    println!(
550        "  pfor  {{BLOCK}} @list [, progress => EXPR] {G}//{N} Parallel foreach across all cores"
551    );
552    println!(
553        "  psort {{BLOCK}} @list [, progress => EXPR] {G}//{N} Parallel sort across all cores"
554    );
555    println!(
556        "  @list |> reduce {{BLOCK}}   {G}//{N} Sequential left fold ($a accum, $b next element); also reduce {{BLOCK}} @list"
557    );
558    println!(
559        "  @list |> preduce {{BLOCK}} [, progress => EXPR] {G}//{N} Parallel tree fold (rayon; associative ops only); also preduce {{BLOCK}} @list"
560    );
561    println!(
562        "  @list |> preduce_init EXPR, {{BLOCK}} [, progress => EXPR] {G}//{N} Parallel fold with identity; also preduce_init EXPR, {{BLOCK}} @list"
563    );
564    println!(
565        "  @list |> pmap_reduce {{MAP}} {{REDUCE}} [, progress => EXPR] {G}//{N} Fused parallel map + tree reduce; also pmap_reduce {{MAP}} {{REDUCE}} @list"
566    );
567    println!(
568        "  fan [N] {{BLOCK}} [, progress => EXPR]  {G}//{N} Execute BLOCK N times (default N = rayon pool; $_ = index); progress may follow }} without a comma"
569    );
570    println!(
571        "  fan_cap [N] {{BLOCK}} [, progress => EXPR]  {G}//{N} Like fan; returns list of block return values (index order)"
572    );
573    println!("{C}  ── TYPING (stryke) ───────────────────────────────────{N}");
574    println!(
575        "  typed my \\$x : Int|Str|Float  {G}//{N} Optional scalar types; runtime checks on assign"
576    );
577    println!(
578        "  fn (\\$a: Int, \\$b: Str) {{}}   {G}//{N} Typed sub params; runtime checks on call"
579    );
580    println!("{C}  ── SERIALIZATION (stryke) ───────────────────────────────{N}");
581    println!(
582        "  str \\$val / stringify \\$val  {G}//{N} Convert any value to parseable stryke literal"
583    );
584    println!("  eval str \\$fn              {G}//{N} Round-trip: serialize + deserialize coderefs");
585    println!("{C}  ── POSITIONAL ─────────────────────────────────────────{N}");
586    println!("  [programfile]          {G}//{N} Perl script to execute");
587    println!("  [arguments]            {G}//{N} Arguments passed to script (@ARGV)");
588    println!();
589    println!();
590    println!("{C}  ── SYSTEM ─────────────────────────────────────────{N}");
591    println!("{M}  v{version} {N}// {Y}(c) MenkeTechnologies{N}");
592    println!("{M}  There is more than one way to do it — in parallel.{N}");
593    println!("{Y}  >>> PARSE. EXECUTE. PARALLELIZE. OWN YOUR CORES. <<<{N}");
594    println!("{C} ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░{N}");
595}
596
597/// `-M` / `-m` prelude prepended to each program line (shared with REPL).
598pub(crate) fn module_prelude(cli: &Cli) -> String {
599    let mut full_code = String::new();
600    for module in &cli.use_module {
601        if let Some((mod_name, args)) = module.split_once('=') {
602            full_code.push_str(&format!(
603                "use {} qw({});\n",
604                mod_name,
605                args.replace(',', " ")
606            ));
607        } else {
608            full_code.push_str(&format!("use {};\n", module));
609        }
610    }
611    for module in &cli.use_module_no_import {
612        if let Some(rest) = module.strip_prefix('-') {
613            full_code.push_str(&format!("no {};\n", rest));
614        } else {
615            full_code.push_str(&format!("use {} ();\n", module));
616        }
617    }
618    full_code
619}
620
621/// Like `perl`, arguments after the script (or after `-e` / `-E` code) are passed to the program
622/// unchanged, including tokens that look like long options (`--regex`, …). Clap rejects unknown
623/// `--flags` unless they appear after `--`; we find the Perl-consistent split and insert `--`
624/// before the first script argument when needed.
625fn parse_cli_prelude(args: &[String]) -> Option<Cli> {
626    if args.len() <= 1 {
627        return None;
628    }
629    // User already used `--` as the end-of-options delimiter; let clap handle it.
630    if args[1..].iter().any(|s| s == "--") {
631        return None;
632    }
633    for k in (1..=args.len()).rev() {
634        let trial: Vec<String> = if k == args.len() {
635            args.to_vec()
636        } else {
637            let mut t = args[..k].to_vec();
638            t.push("--".to_string());
639            t.extend(args[k..].iter().cloned());
640            t
641        };
642        let Some(cli) = Cli::try_parse_from(&trial).ok() else {
643            continue;
644        };
645        if cli.args.as_slice() == args[k..].as_ref() {
646            return Some(cli);
647        }
648    }
649    None
650}
651
652/// When `-e` / `-E` supplies the program, the optional positional `SCRIPT` is actually the first
653/// `@ARGV` element (Perl semantics), not a second script path. Fold it into `args`.
654fn normalize_argv_after_dash_e(cli: &mut Cli) {
655    if (!cli.execute.is_empty() || !cli.execute_features.is_empty()) && cli.script.is_some() {
656        let mut v = vec![cli.script.take().unwrap()];
657        v.append(&mut cli.args);
658        cli.args = v;
659    }
660}
661
662/// Unique temp path next to `target` for atomic in-place replace (`rename` into place).
663fn adjacent_temp_path(target: &Path) -> PathBuf {
664    let dir = target.parent().unwrap_or_else(|| Path::new("."));
665    let name = target
666        .file_name()
667        .map(|s| s.to_string_lossy().into_owned())
668        .unwrap_or_else(|| "file".to_string());
669    let rnd: u32 = rand::thread_rng().gen();
670    dir.join(format!("{name}.stryke-tmp-{rnd}"))
671}
672
673/// Write `new_content` to `path` in place; optional backup `path` + `inplace_edit` (Perl `$^I`).
674fn commit_in_place_edit(path: &Path, inplace_edit: &str, new_content: &str) -> std::io::Result<()> {
675    let tmp = adjacent_temp_path(path);
676    std::fs::write(&tmp, new_content)?;
677    if !inplace_edit.is_empty() {
678        let backup = PathBuf::from(format!("{}{}", path.display(), inplace_edit));
679        let _ = std::fs::remove_file(&backup);
680        std::fs::rename(path, &backup)?;
681    }
682    std::fs::rename(&tmp, path)?;
683    Ok(())
684}
685
686/// Perl `<>` leaves the IRS in `$_`. `-l` chomps each record on the way in (and then re-appends
687/// `$\` on every `print` so output matches input separator-for-separator). Implement that here:
688/// reader gives us the raw record (with IRS preserved); without `-l` we pass it through;
689/// with `-l` we strip one trailing separator byte (and `\r` adjacent for `\n`).
690fn line_mode_input_record(cli: &Cli, l: String, sep_byte: Option<u8>) -> String {
691    if cli.line_ending.is_none() {
692        return l;
693    }
694    // `-l` chomp: strip one trailing IRS byte.
695    let mut bytes = l.into_bytes();
696    match sep_byte {
697        Some(b'\n') => {
698            if bytes.last() == Some(&b'\n') {
699                bytes.pop();
700                if bytes.last() == Some(&b'\r') {
701                    bytes.pop();
702                }
703            }
704        }
705        Some(byte) => {
706            if bytes.last() == Some(&byte) {
707                bytes.pop();
708            }
709        }
710        // Slurp (IRS = undef): perl `-l` with no IRS strips `\n`.
711        None => {
712            if bytes.last() == Some(&b'\n') {
713                bytes.pop();
714            }
715        }
716    }
717    String::from_utf8(bytes).unwrap_or_default()
718}
719
720/// `-n` / `-p` input loop: `@ARGV` files when non-empty, else stdin; `-i` rewrites named files.
721/// Translate the interpreter's configured input record separator (`$/`) into a single byte
722/// for `read_logical_line_perl_compat_with_sep`. Mirrors perl's documented `-0` / `-0NN`
723/// semantics — the IRS is the byte that ends each record.
724///
725/// - `None` (slurp mode set independently of this path) → caller treats as `None` and
726///   reads the full file; this fn keeps `Some(b'\n')` as a safe fallback so the
727///   line-mode loop never silently changes behavior.
728/// - One-char string → that byte
729/// - Multi-char string → first byte (perl falls back the same way when given more)
730fn irs_separator_byte(irs: &Option<String>) -> Option<u8> {
731    match irs {
732        None => Some(b'\n'),
733        Some(s) => s.as_bytes().first().copied().or(Some(b'\n')),
734    }
735}
736
737fn run_line_mode_loop(
738    cli: &Cli,
739    interp: &mut VMHelper,
740    program: &Program,
741    slurp: bool,
742) -> Result<(), StrykeError> {
743    let sep_byte = irs_separator_byte(&interp.irs);
744    let inplace = cli.inplace.is_some();
745    let use_argv_files = !interp.argv.is_empty();
746    let suppressed_stdout_for_inplace = inplace && use_argv_files;
747    let print_to_stdout = cli.print_mode && !suppressed_stdout_for_inplace;
748    // With `-i` and named files, per-line print is suppressed; files are independent, so rayon can
749    // process them in parallel (stock `perl` processes `@ARGV` files sequentially).
750    let parallel_argv_inplace = inplace && use_argv_files;
751
752    if slurp {
753        if use_argv_files {
754            if parallel_argv_inplace {
755                let template = Mutex::new(interp.line_mode_worker_clone());
756                let paths = interp.argv.clone();
757                paths.into_par_iter().try_for_each(|path| {
758                    let mut local = template
759                        .lock()
760                        .expect("line-mode template mutex poisoned")
761                        .line_mode_worker_clone();
762                    local.line_number = 0;
763                    local.argv_current_file = path.clone();
764                    let content = read_file_text_perl_compat(&path).map_err(|e| {
765                        StrykeError::new(
766                            ErrorKind::IO,
767                            format!("Can't open {}: {}", path, e),
768                            0,
769                            "-e",
770                        )
771                    })?;
772                    if let Some(output) = local.process_line(&content, program, true)? {
773                        commit_in_place_edit(Path::new(&path), &local.inplace_edit, &output)
774                            .map_err(|e| StrykeError::new(ErrorKind::IO, e.to_string(), 0, "-e"))?;
775                    }
776                    Ok(())
777                })?;
778            } else {
779                for path in interp.argv.clone() {
780                    interp.line_number = 0;
781                    interp.argv_current_file = path.clone();
782                    let content = read_file_text_perl_compat(&path).map_err(|e| {
783                        StrykeError::new(
784                            ErrorKind::IO,
785                            format!("Can't open {}: {}", path, e),
786                            0,
787                            "-e",
788                        )
789                    })?;
790                    if let Some(output) = interp.process_line(&content, program, true)? {
791                        if inplace {
792                            commit_in_place_edit(Path::new(&path), &interp.inplace_edit, &output)
793                                .map_err(|e| {
794                                    StrykeError::new(ErrorKind::IO, e.to_string(), 0, "-e")
795                                })?;
796                        } else if cli.print_mode {
797                            print!("{}", output);
798                            let _ = io::stdout().flush();
799                        }
800                    }
801                }
802            }
803        } else {
804            let mut input = String::new();
805            let mut raw = Vec::new();
806            let _ = IoRead::read_to_end(&mut io::stdin(), &mut raw);
807            input.push_str(&decode_utf8_or_latin1(&raw));
808            if let Some(output) = interp.process_line(&input, program, true)? {
809                if print_to_stdout {
810                    print!("{}", output);
811                    let _ = io::stdout().flush();
812                }
813            }
814        }
815        return Ok(());
816    }
817
818    if use_argv_files {
819        if parallel_argv_inplace {
820            let template = Mutex::new(interp.line_mode_worker_clone());
821            let paths = interp.argv.clone();
822            paths.into_par_iter().try_for_each(|path| {
823                let mut local = template
824                    .lock()
825                    .expect("line-mode template mutex poisoned")
826                    .line_mode_worker_clone();
827                local.line_number = 0;
828                local.argv_current_file = path.clone();
829                let file = File::open(&path).map_err(|e| {
830                    StrykeError::new(
831                        ErrorKind::IO,
832                        format!("Can't open {}: {}", path, e),
833                        0,
834                        "-e",
835                    )
836                })?;
837                let mut reader = BufReader::new(file);
838                let mut accumulated = String::new();
839                let mut pending: Option<String> = None;
840                loop {
841                    let l = if let Some(s) = pending.take() {
842                        s
843                    } else {
844                        match read_logical_line_perl_compat_with_sep(&mut reader, sep_byte)
845                            .map_err(|e| {
846                                StrykeError::new(
847                                    ErrorKind::IO,
848                                    format!("Error reading {}: {}", path, e),
849                                    0,
850                                    "-e",
851                                )
852                            })? {
853                            None => break,
854                            Some(s) => s,
855                        }
856                    };
857                    let is_last =
858                        match read_logical_line_perl_compat_with_sep(&mut reader, sep_byte)
859                            .map_err(|e| {
860                                StrykeError::new(
861                                    ErrorKind::IO,
862                                    format!("Error reading {}: {}", path, e),
863                                    0,
864                                    "-e",
865                                )
866                            })? {
867                            None => true,
868                            Some(next) => {
869                                pending = Some(next);
870                                false
871                            }
872                        };
873                    let input = line_mode_input_record(cli, l, sep_byte);
874                    if let Some(output) = local.process_line(&input, program, is_last)? {
875                        accumulated.push_str(&output);
876                    }
877                }
878                commit_in_place_edit(Path::new(&path), &local.inplace_edit, &accumulated)
879                    .map_err(|e| StrykeError::new(ErrorKind::IO, e.to_string(), 0, "-e"))?;
880                Ok(())
881            })?;
882        } else {
883            for path in interp.argv.clone() {
884                interp.line_number = 0;
885                interp.argv_current_file = path.clone();
886                let file = File::open(&path).map_err(|e| {
887                    StrykeError::new(
888                        ErrorKind::IO,
889                        format!("Can't open {}: {}", path, e),
890                        0,
891                        "-e",
892                    )
893                })?;
894                let mut reader = BufReader::new(file);
895                let mut accumulated = String::new();
896                let mut pending: Option<String> = None;
897                loop {
898                    let l = if let Some(s) = pending.take() {
899                        s
900                    } else {
901                        match read_logical_line_perl_compat_with_sep(&mut reader, sep_byte)
902                            .map_err(|e| {
903                                StrykeError::new(
904                                    ErrorKind::IO,
905                                    format!("Error reading {}: {}", path, e),
906                                    0,
907                                    "-e",
908                                )
909                            })? {
910                            None => break,
911                            Some(s) => s,
912                        }
913                    };
914                    let is_last =
915                        match read_logical_line_perl_compat_with_sep(&mut reader, sep_byte)
916                            .map_err(|e| {
917                                StrykeError::new(
918                                    ErrorKind::IO,
919                                    format!("Error reading {}: {}", path, e),
920                                    0,
921                                    "-e",
922                                )
923                            })? {
924                            None => true,
925                            Some(next) => {
926                                pending = Some(next);
927                                false
928                            }
929                        };
930                    let input = line_mode_input_record(cli, l, sep_byte);
931                    if let Some(output) = interp.process_line(&input, program, is_last)? {
932                        if print_to_stdout {
933                            print!("{}", output);
934                            let _ = io::stdout().flush();
935                        }
936                        if inplace {
937                            accumulated.push_str(&output);
938                        }
939                    }
940                }
941                if inplace {
942                    commit_in_place_edit(Path::new(&path), &interp.inplace_edit, &accumulated)
943                        .map_err(|e| StrykeError::new(ErrorKind::IO, e.to_string(), 0, "-e"))?;
944                }
945            }
946        }
947    } else {
948        // Read stdin with `read_line` and **do not** hold `StdinLock` across `process_line` (the body
949        // may call `<>` / `readline`, which also locks stdin — exclusive lock would deadlock).
950        //
951        // Peek-reading the next line to set `is_last` for `eof` would consume that line from the
952        // kernel buffer; push it onto [`Interpreter::line_mode_stdin_pending`] so body `<>` reads it
953        // first (Perl shares one fd between the implicit `while (<>)` and inner `readline`).
954        interp.line_mode_stdin_pending.clear();
955        loop {
956            let mut current = String::new();
957            let n = if let Some(queued) = interp.line_mode_stdin_pending.pop_front() {
958                current = queued;
959                current.len()
960            } else {
961                let mut lock = io::stdin().lock();
962                read_line_perl_compat_with_sep(&mut lock, &mut current, sep_byte).map_err(|e| {
963                    StrykeError::new(ErrorKind::IO, format!("Error reading stdin: {e}"), 0, "-e")
964                })?
965            };
966            if n == 0 {
967                break;
968            }
969            let (is_last, peek_line) = {
970                let mut lock = io::stdin().lock();
971                let mut peek = String::new();
972                let n = read_line_perl_compat_with_sep(&mut lock, &mut peek, sep_byte).map_err(
973                    |e| {
974                        StrykeError::new(
975                            ErrorKind::IO,
976                            format!("Error reading stdin: {e}"),
977                            0,
978                            "-e",
979                        )
980                    },
981                )?;
982                if n == 0 {
983                    (true, None)
984                } else {
985                    (false, Some(peek))
986                }
987            };
988            if let Some(pl) = peek_line {
989                interp.line_mode_stdin_pending.push_back(pl);
990            }
991            // perl `<>` leaves the IRS in `$_`; the reader already preserves the trailing
992            // bytes — pass the raw record to `line_mode_input_record` which applies `-l` chomp
993            // when configured.
994            let input = line_mode_input_record(cli, current, sep_byte);
995            match interp.process_line(&input, program, is_last) {
996                Ok(Some(output)) => {
997                    if print_to_stdout {
998                        print!("{}", output);
999                        let _ = io::stdout().flush();
1000                    }
1001                }
1002                Ok(None) => {}
1003                Err(e) => return Err(e),
1004            }
1005        }
1006    }
1007    Ok(())
1008}
1009
1010pub(crate) fn configure_interpreter(cli: &Cli, interp: &mut VMHelper, filename: &str) {
1011    interp.set_file(filename);
1012    interp.warnings = (cli.warnings || cli.all_warnings) && !cli.no_warnings;
1013    interp.auto_split = cli.auto_split;
1014    interp.field_separator = cli.field_separator.clone();
1015    interp.program_name = filename.to_string();
1016
1017    if let Some(ref sep) = cli.input_separator {
1018        match sep.as_deref() {
1019            None | Some("") => interp.irs = Some("\0".to_string()),
1020            Some("777") => interp.irs = None, // perl `-0777` enables slurp mode
1021            Some(oct_str) => {
1022                if let Ok(val) = u32::from_str_radix(oct_str, 8) {
1023                    if let Some(ch) = char::from_u32(val) {
1024                        interp.irs = Some(ch.to_string());
1025                    }
1026                }
1027            }
1028        }
1029    }
1030
1031    if let Some(ref octnum) = cli.line_ending {
1032        match octnum.as_deref() {
1033            None | Some("") => {
1034                interp.ors = "\n".to_string();
1035            }
1036            Some(oct_str) => {
1037                if let Ok(val) = u32::from_str_radix(oct_str, 8) {
1038                    if let Some(ch) = char::from_u32(val) {
1039                        interp.ors = ch.to_string();
1040                    }
1041                }
1042            }
1043        }
1044    }
1045
1046    if (cli.taint_check || cli.taint_warn) && cli.warnings {
1047        eprintln!("stryke: taint mode acknowledged but not enforced");
1048    }
1049
1050    if let Some(ref ext_opt) = cli.inplace {
1051        interp.inplace_edit = ext_opt.clone().unwrap_or_default();
1052    }
1053
1054    // Trailing arguments become `@ARGV` for `perl script.pl …` and for `perl -e '…' …` (Perl
1055    // compatibility).
1056    let mut argv: Vec<String> =
1057        if cli.script.is_some() || !cli.execute.is_empty() || !cli.execute_features.is_empty() {
1058            cli.args.clone()
1059        } else {
1060            Vec::new()
1061        };
1062
1063    if cli.switch_parsing {
1064        let mut switches_done = false;
1065        let mut remaining = Vec::new();
1066        for arg in &argv {
1067            if switches_done || !arg.starts_with('-') || arg == "--" {
1068                if arg == "--" {
1069                    switches_done = true;
1070                } else {
1071                    remaining.push(arg.clone());
1072                }
1073            } else {
1074                let switch = &arg[1..];
1075                if let Some((name, val)) = switch.split_once('=') {
1076                    let _ = interp
1077                        .scope
1078                        .set_scalar(name, crate::value::StrykeValue::string(val.to_string()));
1079                } else {
1080                    let _ = interp
1081                        .scope
1082                        .set_scalar(switch, crate::value::StrykeValue::integer(1));
1083                }
1084            }
1085        }
1086        argv = remaining;
1087    }
1088
1089    interp.argv = argv.clone();
1090    interp.scope.declare_array(
1091        "ARGV",
1092        argv.into_iter()
1093            .map(crate::value::StrykeValue::string)
1094            .collect(),
1095    );
1096
1097    // Order: `-I`, in-tree `vendor/perl` (pure-Perl modules, …), system `perl`’s @INC, script
1098    // dir, `STRYKE_INC`, then `.` (deduped).
1099    let mut inc_paths: Vec<String> = cli.include.clone();
1100    let vendor = crate::vendor_perl_inc_path();
1101    if vendor.is_dir() {
1102        crate::perl_inc::push_unique_string_paths(
1103            &mut inc_paths,
1104            vec![vendor.to_string_lossy().into_owned()],
1105        );
1106    }
1107    crate::perl_inc::push_unique_string_paths(
1108        &mut inc_paths,
1109        crate::perl_inc::paths_from_system_perl(),
1110    );
1111    if filename != "-e" && filename != "-" && filename != "repl" {
1112        if let Some(parent) = std::path::Path::new(filename).parent() {
1113            if !parent.as_os_str().is_empty() {
1114                crate::perl_inc::push_unique_string_paths(
1115                    &mut inc_paths,
1116                    vec![parent.to_string_lossy().into_owned()],
1117                );
1118            }
1119        }
1120    }
1121    if let Ok(extra) = std::env::var("STRYKE_INC") {
1122        let extra: Vec<String> = std::env::split_paths(&extra)
1123            .map(|p| p.to_string_lossy().into_owned())
1124            .collect();
1125        crate::perl_inc::push_unique_string_paths(&mut inc_paths, extra);
1126    }
1127    crate::perl_inc::push_unique_string_paths(&mut inc_paths, vec![".".to_string()]);
1128    let inc_dirs: Vec<crate::value::StrykeValue> = inc_paths
1129        .into_iter()
1130        .map(crate::value::StrykeValue::string)
1131        .collect();
1132    interp.scope.declare_array("INC", inc_dirs);
1133
1134    if cli.debugger.is_some() {
1135        // Install the TTY debugger (REPL on stdin/stderr, perl -d style).
1136        // The DAP debugger is wired separately via `st --dap` in lib.rs::run().
1137        let mut dbg = crate::debugger::Debugger::new();
1138        if filename != "-e" && filename != "-" && filename != "repl" {
1139            dbg.set_file(filename);
1140            if let Ok(src) = std::fs::read_to_string(filename) {
1141                dbg.load_source(&src);
1142            }
1143        }
1144        interp.debugger = Some(dbg);
1145    }
1146}
1147
1148/// Emit profiler output.
1149///
1150/// `--flame` + piped stdout → SVG flamegraph to saved fd.
1151/// `--flame` + TTY stdout  → colored terminal bars to stderr.
1152/// `--profile` (no flame)  → plain text report to stderr.
1153fn emit_profiler_report(
1154    p: &mut crate::profiler::Profiler,
1155    flame_out: &Option<File>,
1156    flame_tty: bool,
1157) {
1158    if let Some(f) = flame_out {
1159        // stdout was piped — write SVG to the saved fd
1160        let mut w = io::BufWriter::new(f);
1161        if let Err(e) = p.render_flame_svg(&mut w) {
1162            eprintln!("stryke --flame: {}", e);
1163        }
1164    } else if flame_tty {
1165        // stdout is a TTY — render colored bars to stderr
1166        p.render_flame_tty();
1167    } else {
1168        // plain --profile
1169        p.print_report();
1170    }
1171}
1172
1173/// Run one `stryke` invocation inside a host process and return its exit
1174/// status.
1175///
1176/// This is the entry point for a host with no fork to spend — the `stryke`
1177/// shell builtin in zshrs-native, where strykelang is linked into the shell and
1178/// a one-liner costs no process at all. `argv` is the whole command line,
1179/// `argv[0]` included, exactly as `main` would have received it.
1180///
1181/// [`hosted::run`] is what makes the promise to return keep: an `exit` from
1182/// anywhere in the command line layer unwinds to here instead of taking the
1183/// shell down, a panic becomes a status, and the working directory the host had
1184/// is restored.
1185pub fn run_argv(argv: &[String]) -> i32 {
1186    crate::hosted::run(|| run(argv))
1187}
1188
1189thread_local! {
1190    /// The name this invocation was made under — argv[0]'s basename.
1191    ///
1192    /// Replaces `env!("CARGO_BIN_NAME")`, which existed only while the command
1193    /// line was a binary target. Thread-local rather than a `OnceLock` because
1194    /// a host dispatches many invocations in one process and each may have been
1195    /// typed under a different name: `s`, `st`, `stryke`.
1196    static INVOKED_AS: std::cell::RefCell<String> =
1197        const { std::cell::RefCell::new(String::new()) };
1198}
1199
1200/// The name this invocation was made under, defaulting to `stryke`.
1201fn invoked_as() -> String {
1202    INVOKED_AS.with(|n| {
1203        let n = n.borrow();
1204        if n.is_empty() {
1205            "stryke".to_string()
1206        } else {
1207            n.clone()
1208        }
1209    })
1210}
1211
1212fn run(full_argv: &[String]) -> i32 {
1213    INVOKED_AS.with(|n| {
1214        *n.borrow_mut() = full_argv
1215            .first()
1216            .map(|a| {
1217                Path::new(a)
1218                    .file_name()
1219                    .map_or(a.clone(), |b| b.to_string_lossy().into_owned())
1220            })
1221            .unwrap_or_default();
1222    });
1223
1224    // Restore the default SIGPIPE disposition. The Rust runtime sets SIGPIPE to
1225    // SIG_IGN at startup, which turns a closed downstream pipe into an io::Error
1226    // that `println!`/stdout writes escalate to a panic ("failed printing to
1227    // stdout: Broken pipe", exit 101). Every real Unix filter — awk, perl, grep —
1228    // takes the default action and dies quietly when the reader goes away. stryke
1229    // absorbs awk's one-liner role, so `s '...' | head` (or quitting a pager) is
1230    // the canonical case; reset to SIG_DFL so we terminate silently like them.
1231    //
1232    // Not hosted: the disposition belongs to the process, and the host is not
1233    // ours to change. A shell that linked stryke in would inherit SIG_DFL and
1234    // die on the next write to a closed pipe — the shell's own SIGPIPE policy
1235    // stands.
1236    #[cfg(unix)]
1237    if !crate::hosted::is_hosted() {
1238        unsafe {
1239            libc::signal(libc::SIGPIPE, libc::SIG_DFL);
1240        }
1241    }
1242
1243    // AOT: if the running binary carries an embedded script trailer, execute it and
1244    // exit. Bypasses clap, flags, REPL — the embedded binary behaves like a plain native
1245    // program: all command-line args become `@ARGV` for the embedded script. The probe
1246    // costs one file open + one 32-byte read (~50 µs) on the no-trailer path.
1247    if let Ok(exe) = std::env::current_exe() {
1248        if let Some(payload) = crate::aot::try_load_embedded(&exe) {
1249            let argv: Vec<String> = full_argv.iter().skip(1).cloned().collect();
1250            match payload {
1251                crate::aot::EmbeddedPayload::Script(embedded) => {
1252                    crate::hosted::exit(run_embedded_script(embedded, argv));
1253                }
1254                crate::aot::EmbeddedPayload::Bundle(bundle) => {
1255                    crate::hosted::exit(run_embedded_bundle(bundle, argv));
1256                }
1257            }
1258        }
1259    }
1260
1261    // First-run seed: write `~/.stryke/config.toml` if missing. Silent
1262    // no-op when the file already exists or `STRYKE_NO_CONFIG` is set.
1263    // Done before any subcommand dispatch so every entry point (REPL,
1264    // script run, test pool worker, controller, ...) sees the same
1265    // populated config dir on first launch.
1266    repl::ensure_default_config_seeded();
1267
1268    let mut args = expand_perl_bundled_argv(full_argv.to_vec());
1269
1270    // `--record` — opt-in wall-clock recording. Strip the flag from argv
1271    // (so downstream parsing doesn't choke on it), set `STRYKE_RECORD=1`
1272    // so child processes (test workers, spawned scripts) inherit the
1273    // request, then install the perf recorder. Child stryke processes
1274    // spawned by `s --record t TESTS...` see only the inherited env var
1275    // (not `--record` in their own argv) and still record one row per
1276    // invocation. The atexit handler fires for every exit path including
1277    // `crate::hosted::exit(N)`.
1278    let record_requested = {
1279        let pos = args.iter().position(|a| a == "--record");
1280        if let Some(i) = pos {
1281            args.remove(i);
1282            std::env::set_var("STRYKE_RECORD", "1");
1283            true
1284        } else {
1285            false
1286        }
1287    };
1288    if record_requested || crate::perf_recorder::recording_enabled_in_env() {
1289        let path = crate::perf_recorder::classify_invocation(&args);
1290        crate::perf_recorder::install(path, args.clone());
1291    }
1292
1293    // `stryke --test-worker` — pool-worker mode: read test paths from
1294    // stdin, fork per request, run test in-process in the child, write
1295    // JSON result to stdout, child `_exit`s. The worker process stays
1296    // hot across thousands of tests; only the forked grandchildren
1297    // execute test bytecode.
1298    if args.len() == 2 && args[1] == "--test-worker" {
1299        crate::hosted::exit(crate::cli_runners::run_test_worker_loop());
1300    }
1301
1302    if args.len() == 2 && args[1] == "--remote-worker" {
1303        // Persistent v3 session loop: HELLO → SESSION_INIT → many JOBs → SHUTDOWN.
1304        // The basic v1 one-shot loop is still reachable via `--remote-worker-v1` for the
1305        // round-trip integration test.
1306        crate::hosted::exit(crate::remote_wire::run_remote_worker_session());
1307    }
1308    if args.len() == 2 && args[1] == "--remote-worker-v1" {
1309        crate::hosted::exit(crate::remote_wire::run_remote_worker_stdio());
1310    }
1311
1312    if args.len() == 2 && args[1] == "--lsp" {
1313        crate::hosted::exit(crate::run_lsp_stdio());
1314    }
1315
1316    if args.len() >= 2 && args[1] == "--dap" {
1317        // `st --dap` (stdio) or `st --dap HOST:PORT` (TCP socket — used by
1318        // the JetBrains plugin to avoid sharing stdout with OSProcessHandler).
1319        let rest: Vec<String> = args.iter().skip(2).cloned().collect();
1320        crate::hosted::exit(crate::dap::run_with_args(&rest));
1321    }
1322
1323    // `stryke agent` — distributed load testing agent
1324    if args.len() >= 2 && args[1] == "agent" {
1325        if args.len() >= 3 && (args[2] == "--help" || args[2] == "-h") {
1326            crate::agent::print_help();
1327            crate::hosted::exit(0);
1328        }
1329        let mut config_path: Option<&str> = None;
1330        let mut controller_override: Option<String> = None;
1331        let mut port_override: Option<u16> = None;
1332        let mut i = 2;
1333        while i < args.len() {
1334            match args[i].as_str() {
1335                "-c" | "--config" if i + 1 < args.len() => {
1336                    config_path = Some(&args[i + 1]);
1337                    i += 2;
1338                }
1339                "--controller" if i + 1 < args.len() => {
1340                    controller_override = Some(args[i + 1].clone());
1341                    i += 2;
1342                }
1343                "--port" if i + 1 < args.len() => {
1344                    port_override = args[i + 1].parse().ok();
1345                    i += 2;
1346                }
1347                _ => i += 1,
1348            }
1349        }
1350        crate::hosted::exit(crate::agent::run_agent_with_overrides(
1351            config_path,
1352            controller_override.as_deref(),
1353            port_override,
1354        ));
1355    }
1356
1357    // `stryke controller` — distributed load testing controller REPL
1358    if args.len() >= 2 && args[1] == "controller" {
1359        if args.len() >= 3 && (args[2] == "--help" || args[2] == "-h") {
1360            crate::controller::print_help();
1361            crate::hosted::exit(0);
1362        }
1363        let mut bind = "0.0.0.0";
1364        let mut port = 9999u16;
1365        let mut i = 2;
1366        while i < args.len() {
1367            match args[i].as_str() {
1368                "--bind" | "-b" if i + 1 < args.len() => {
1369                    bind = &args[i + 1];
1370                    i += 2;
1371                }
1372                "--port" | "-p" if i + 1 < args.len() => {
1373                    port = args[i + 1].parse().unwrap_or(9999);
1374                    i += 2;
1375                }
1376                _ => i += 1,
1377            }
1378        }
1379        crate::hosted::exit(crate::controller::run_controller(bind, port));
1380    }
1381
1382    // `stryke ai "prompt"` subcommand — quick CLI access to the ai
1383    // builtin without writing a script. Reads the prompt from argv,
1384    // or stdin if argv is empty.
1385    if args.len() >= 2 && args[1] == "ai" {
1386        crate::hosted::exit(run_ai_subcommand(&args[2..]));
1387    }
1388
1389    // `stryke build SCRIPT -o OUT` subcommand: intercept before clap so `build` does not have
1390    // to be added to the main `Cli` struct (keeping the perl-compatible flag surface clean).
1391    if args.len() >= 2 && args[1] == "build" {
1392        crate::hosted::exit(run_build_subcommand(&args[2..]));
1393    }
1394
1395    // `stryke convert FILE...` subcommand: convert Perl source to stryke syntax with |> pipes.
1396    if args.len() >= 2 && args[1] == "convert" {
1397        crate::hosted::exit(run_convert_subcommand(&args[2..]));
1398    }
1399
1400    // `stryke deconvert FILE...` subcommand: convert stryke .stk files back to standard Perl .pl syntax.
1401    if args.len() >= 2 && args[1] == "deconvert" {
1402        crate::hosted::exit(run_deconvert_subcommand(&args[2..]));
1403    }
1404
1405    // `stryke docs [TOPIC]` subcommand: built-in documentation browser.
1406    // `help` / `h` are aliases, mirroring the `docs`/`help`/`h` builtin
1407    // spellings — e.g. `stryke help style` prints the style guide.
1408    if args.len() >= 2 && (args[1] == "docs" || args[1] == "help" || args[1] == "h") {
1409        crate::hosted::exit(run_doc_subcommand(&args[2..]));
1410    }
1411
1412    // `stryke fmt [-i] FILE...` — format stryke source files.
1413    if args.len() >= 2 && args[1] == "fmt" {
1414        crate::hosted::exit(run_fmt_subcommand(&args[2..]));
1415    }
1416
1417    if args.len() >= 2 && args[1] == "minify" {
1418        crate::hosted::exit(run_minify_subcommand(&args[2..]));
1419    }
1420
1421    // `stryke bench [FILE|DIR]` — discover and run benchmark files.
1422    if args.len() >= 2 && args[1] == "bench" {
1423        crate::hosted::exit(run_bench_subcommand(&args[0], &args[2..]));
1424    }
1425
1426    // `stryke init [NAME]` — scaffold a new stryke project (with stryke.toml).
1427    if args.len() >= 2 && args[1] == "init" {
1428        crate::hosted::exit(crate::pkg::commands::cmd_init(
1429            args.get(2).map(|s| s.as_str()),
1430        ));
1431    }
1432
1433    // `stryke new NAME` — scaffold a new project at ./NAME/.
1434    if args.len() >= 2 && args[1] == "new" {
1435        if args.len() < 3 {
1436            eprintln!("usage: stryke new NAME");
1437            crate::hosted::exit(1);
1438        }
1439        crate::hosted::exit(crate::pkg::commands::cmd_new(&args[2]));
1440    }
1441
1442    // Package manager subcommands. See docs/PACKAGE_REGISTRY.md.
1443    if args.len() >= 2 && args[1] == "add" {
1444        crate::hosted::exit(crate::pkg::commands::cmd_add(&args[2..]));
1445    }
1446    if args.len() >= 2 && args[1] == "remove" {
1447        crate::hosted::exit(crate::pkg::commands::cmd_remove(&args[2..]));
1448    }
1449    if args.len() >= 2 && matches!(args[1].as_str(), "install" | "i") {
1450        let rest = &args[2..];
1451        if rest.iter().any(|a| a == "-g" || a == "--global") {
1452            let filtered: Vec<String> = rest
1453                .iter()
1454                .filter(|a| !matches!(a.as_str(), "-g" | "--global"))
1455                .cloned()
1456                .collect();
1457            crate::hosted::exit(crate::pkg::commands::cmd_install_global(&filtered));
1458        } else {
1459            crate::hosted::exit(crate::pkg::commands::cmd_install(rest));
1460        }
1461    }
1462    if args.len() >= 2 && args[1] == "tree" {
1463        crate::hosted::exit(crate::pkg::commands::cmd_tree(&args[2..]));
1464    }
1465    if args.len() >= 2 && args[1] == "info" {
1466        crate::hosted::exit(crate::pkg::commands::cmd_info(&args[2..]));
1467    }
1468    if args.len() >= 2 && matches!(args[1].as_str(), "update" | "up" | "upgrade") {
1469        if args[2..].iter().any(|a| a == "-g" || a == "--global") {
1470            let filtered: Vec<String> = args[2..]
1471                .iter()
1472                .filter(|a| !matches!(a.as_str(), "-g" | "--global"))
1473                .cloned()
1474                .collect();
1475            crate::hosted::exit(crate::pkg::commands::cmd_upgrade_global(&filtered));
1476        }
1477        if args[1] == "upgrade" {
1478            crate::hosted::exit(crate::pkg::commands::cmd_upgrade_project(&args[2..]));
1479        }
1480        crate::hosted::exit(crate::pkg::commands::cmd_update(&args[2..]));
1481    }
1482    if args.len() >= 2 && args[1] == "outdated" {
1483        crate::hosted::exit(crate::pkg::commands::cmd_outdated(&args[2..]));
1484    }
1485    if args.len() >= 2 && args[1] == "audit" {
1486        crate::hosted::exit(crate::pkg::commands::cmd_audit(&args[2..]));
1487    }
1488    if args.len() >= 2 && args[1] == "vendor" {
1489        crate::hosted::exit(crate::pkg::commands::cmd_vendor(&args[2..]));
1490    }
1491    if args.len() >= 2 && args[1] == "clean" {
1492        crate::hosted::exit(crate::pkg::commands::cmd_clean(&args[2..]));
1493    }
1494    if args.len() >= 2 && args[1] == "search" {
1495        crate::hosted::exit(crate::pkg::commands::cmd_search(&args[2..]));
1496    }
1497    if args.len() >= 2 && matches!(args[1].as_str(), "publish" | "pub") {
1498        crate::hosted::exit(crate::pkg::commands::cmd_publish(&args[2..]));
1499    }
1500    if args.len() >= 2 && args[1] == "yank" {
1501        crate::hosted::exit(crate::pkg::commands::cmd_yank(&args[2..]));
1502    }
1503    // `stryke uninstall -g NAME` and `stryke list -g` for global CLI tools.
1504    if args.len() >= 2 && matches!(args[1].as_str(), "uninstall" | "un") {
1505        let rest = &args[2..];
1506        if rest.iter().any(|a| a == "-g" || a == "--global") {
1507            let filtered: Vec<String> = rest
1508                .iter()
1509                .filter(|a| !matches!(a.as_str(), "-g" | "--global"))
1510                .cloned()
1511                .collect();
1512            crate::hosted::exit(crate::pkg::commands::cmd_uninstall_global(&filtered));
1513        } else {
1514            eprintln!("s uninstall: pass -g for global tools (no per-project uninstall yet)");
1515            crate::hosted::exit(1);
1516        }
1517    }
1518    if args.len() >= 2 && matches!(args[1].as_str(), "list" | "ls") {
1519        let rest = &args[2..];
1520        if rest.iter().any(|a| a == "-g" || a == "--global") {
1521            let filtered: Vec<String> = rest
1522                .iter()
1523                .filter(|a| !matches!(a.as_str(), "-g" | "--global"))
1524                .cloned()
1525                .collect();
1526            crate::hosted::exit(crate::pkg::commands::cmd_list_global(&filtered));
1527        } else {
1528            eprintln!("s list: pass -g to list global tools");
1529            crate::hosted::exit(1);
1530        }
1531    }
1532    if args.len() >= 2 && args[1] == "pkg" {
1533        crate::hosted::exit(crate::pkg::commands::dispatch(&args[2..]));
1534    }
1535
1536    // `stryke repl [--load FILE]` — explicit REPL entry.
1537    if args.len() >= 2 && args[1] == "repl" {
1538        crate::hosted::exit(run_repl_subcommand(&args[2..]));
1539    }
1540
1541    // `stryke run` — three-way dispatch:
1542    //   1. `stryke run NAME` where NAME is a [scripts] entry → npm-style task runner.
1543    //   2. `stryke run FILE.stk` → run that .stk file (legacy behavior).
1544    //   3. `stryke run` with no args → run ./main.stk (legacy behavior).
1545    if args.len() >= 2 && args[1] == "run" {
1546        // Detect script-name dispatch: first positional arg matches a [scripts]
1547        // key in the nearest stryke.toml. Falls through to .stk-file dispatch
1548        // if the manifest is missing or the name doesn't match.
1549        if args.len() >= 3 && !args[2].starts_with('-') && !args[2].ends_with(".stk") {
1550            let candidate = &args[2];
1551            if let Ok(cwd) = std::env::current_dir() {
1552                if let Some(root) = crate::pkg::commands::find_project_root(&cwd) {
1553                    let mp = root.join(crate::pkg::commands::MANIFEST_FILE);
1554                    if let Ok(m) = crate::pkg::manifest::Manifest::from_path(&mp) {
1555                        if m.scripts.contains_key(candidate) {
1556                            crate::hosted::exit(crate::pkg::commands::cmd_run_script(&args[2..]));
1557                        }
1558                    }
1559                }
1560            }
1561        }
1562
1563        let script = if args.len() >= 3 {
1564            args[2].clone()
1565        } else {
1566            // Search for main.stk, then src/main.stk
1567            if std::path::Path::new("main.stk").exists() {
1568                "main.stk".to_string()
1569            } else if std::path::Path::new("src/main.stk").exists() {
1570                "src/main.stk".to_string()
1571            } else {
1572                eprintln!("stryke run: no main.stk found (checked ./main.stk and ./src/main.stk)");
1573                crate::hosted::exit(1);
1574            }
1575        };
1576        // Re-exec self with the script path — isolated interpreter per run.
1577        let exe = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from(&args[0]));
1578        let mut cmd = process::Command::new(exe);
1579        cmd.arg(&script);
1580        if args.len() > 3 {
1581            cmd.args(&args[3..]);
1582        }
1583        let status = cmd.status().unwrap_or_else(|e| {
1584            eprintln!("stryke run: {}", e);
1585            crate::hosted::exit(1);
1586        });
1587        crate::hosted::exit(status.code().unwrap_or(1));
1588    }
1589
1590    // `stryke prun FILE...` — run multiple files in parallel.
1591    if args.len() >= 2 && args[1] == "prun" {
1592        crate::hosted::exit(run_prun_subcommand(&args[0], &args[2..]));
1593    }
1594
1595    // `stryke test [FILE|DIR...]` or `stryke -j 18 t` — run test files.
1596    // Find the first positional arg (skip flags and their values).
1597    let first_positional_idx = {
1598        let mut i = 1;
1599        let flags_with_value = ["-j", "-I", "-M", "-e", "-d"];
1600        while i < args.len() {
1601            if args[i].starts_with('-') {
1602                if flags_with_value.iter().any(|f| args[i] == *f) {
1603                    i += 2; // skip flag + its value
1604                } else {
1605                    i += 1; // skip boolean flag
1606                }
1607            } else {
1608                break;
1609            }
1610        }
1611        if i < args.len() {
1612            Some(i)
1613        } else {
1614            None
1615        }
1616    };
1617    let is_test_subcmd = first_positional_idx
1618        .map(|i| args[i] == "test" || args[i] == "t")
1619        .unwrap_or(false);
1620    if is_test_subcmd {
1621        let subcmd_idx = first_positional_idx.unwrap();
1622        let after_subcmd = subcmd_idx + 1;
1623        // Strip flag-like words from positional targets — they can appear
1624        // before or after the `test` keyword and aren't paths.
1625        let raw_targets: Vec<String> = if after_subcmd < args.len() {
1626            args[after_subcmd..]
1627                .iter()
1628                .filter(|a| {
1629                    let s = a.as_str();
1630                    s != "--no-interop"
1631                        && s != "--quiet"
1632                        && s != "-q"
1633                        && s != "--fork"
1634                        && s != "--inproc"
1635                        && s != "--pool"
1636                })
1637                .cloned()
1638                .collect()
1639        } else {
1640            Vec::new()
1641        };
1642        let j_threads = args
1643            .iter()
1644            .position(|a| a == "-j")
1645            .and_then(|i| args.get(i + 1).cloned());
1646        let no_interop = args.iter().any(|a| a == "--no-interop");
1647        let quiet = args[subcmd_idx + 1..]
1648            .iter()
1649            .any(|a| a == "--quiet" || a == "-q");
1650        // Three runner modes:
1651        //   * default / `--pool` — worker pool of persistent stryke
1652        //                  processes; each worker fork-on-receive per
1653        //                  test. Tests stay isolated (each in its own
1654        //                  forked child) and worker state stays clean
1655        //                  (worker never runs test bytecode). Skips
1656        //                  ~8ms of dyld + crate static-init per test;
1657        //                  ~5× faster than `--fork` on big corpora.
1658        //   * `--fork`   — legacy `posix_spawn` per test. Fully
1659        //                  isolated, slowest, kept for parity / debug.
1660        //   * `--inproc` — opt-in single-process VM-per-test on a
1661        //                  worker thread. Fastest, but tests share
1662        //                  parent address space → can leak state.
1663        let want_fork = args.iter().any(|a| a == "--fork");
1664        let want_inproc = args.iter().any(|a| a == "--inproc");
1665        if want_inproc {
1666            crate::hosted::exit(crate::cli_runners::run_tests_with_mode(
1667                &raw_targets,
1668                j_threads.as_deref(),
1669                no_interop,
1670                quiet,
1671                false,
1672            ));
1673        }
1674        if want_fork {
1675            crate::hosted::exit(crate::cli_runners::run_tests_with_mode(
1676                &raw_targets,
1677                j_threads.as_deref(),
1678                no_interop,
1679                quiet,
1680                true,
1681            ));
1682        }
1683        // Default = pool.
1684        crate::hosted::exit(crate::cli_runners::run_tests_pool(
1685            &raw_targets,
1686            j_threads.as_deref(),
1687            no_interop,
1688            quiet,
1689        ));
1690    }
1691
1692    // `stryke serve PORT SCRIPT` or `stryke serve PORT -e CODE` subcommand.
1693    if args.len() >= 2 && args[1] == "serve" {
1694        crate::hosted::exit(run_serve_subcommand(&args[2..]));
1695    }
1696
1697    // `stryke check FILE...` — parse + compile without executing.
1698    if args.len() >= 2 && args[1] == "check" {
1699        crate::hosted::exit(run_check_subcommand(&args[2..]));
1700    }
1701
1702    // `stryke disasm FILE` — disassemble bytecode.
1703    if args.len() >= 2 && args[1] == "disasm" {
1704        crate::hosted::exit(run_disasm_subcommand(&args[2..]));
1705    }
1706
1707    // `stryke profile FILE` — run with profiling and output structured data.
1708    if args.len() >= 2 && args[1] == "profile" {
1709        crate::hosted::exit(run_profile_subcommand(&args[0], &args[2..]));
1710    }
1711
1712    // `stryke lsp` — start Language Server Protocol over stdio.
1713    if args.len() >= 2 && args[1] == "lsp" {
1714        crate::hosted::exit(crate::run_lsp_stdio());
1715    }
1716
1717    // `stryke completions [SHELL]` — emit shell completions.
1718    if args.len() >= 2 && args[1] == "completions" {
1719        crate::hosted::exit(run_completions_subcommand(&args[2..]));
1720    }
1721
1722    // `stryke ast FILE` — dump AST as JSON.
1723    if args.len() >= 2 && args[1] == "ast" {
1724        crate::hosted::exit(run_ast_subcommand(&args[2..]));
1725    }
1726
1727    // `stryke gen-docs [PATH]` — walk a directory tree (or .), find
1728    // every `.stk` / `.pl` / `.pm` source file, and generate
1729    // Markdown documentation per module. PATH defaults to `.`.
1730    // Output mirrors the source layout under `docs/` (configurable
1731    // via `--out DIR`).
1732    if args.len() >= 2 && args[1] == "gen-docs" {
1733        crate::hosted::exit(run_gen_docs_subcommand(&args[2..]));
1734    }
1735
1736    // Hierarchy: subcommand → builtin → script
1737    // `stryke pin` calls `pin()` builtin, not a script named `pin`.
1738    // `stryke --script pin` forces script lookup when there's a conflict.
1739    // `-e BUILTIN` or `-e 'code'` forces inline code execution.
1740    //
1741    // Check: if args[1] is a known builtin name AND not forced to script AND file doesn't exist,
1742    // execute it as a builtin call. This allows `stryke pin`, `stryke heat`, etc.
1743    let force_script = args.iter().any(|a| a == "--script");
1744    if args.len() >= 2
1745        && !args[1].starts_with('-')
1746        && !force_script
1747        && crate::builtins::is_builtin(&args[1])
1748        && !Path::new(&args[1]).exists()
1749    {
1750        // Execute as `BUILTIN(@ARGV)` where @ARGV is the remaining args
1751        let builtin_name = &args[1];
1752        let builtin_args: Vec<String> = args[2..].to_vec();
1753        crate::hosted::exit(run_builtin_subcommand(builtin_name, &builtin_args));
1754    }
1755
1756    // Fast path: `stryke SCRIPT [ARGS...]` with no dashes anywhere — the common case, and
1757    // clap parsing is the dominant term on `print "hello\n"` (it knocks ~1ms off the
1758    // startup bench). We can't bypass clap when any flag is present, so fall through to the
1759    // full parser in that case.
1760    // Exception: `->>`, `->`, `~>` look like flags but are actually threading operators for
1761    // inline code — detect via `looks_like_code` and treat as script.
1762    let arg1_is_code_not_flag =
1763        args.len() >= 2 && args[1].starts_with('-') && looks_like_code(&args[1]);
1764    let mut cli = if args.len() >= 2
1765        && (!args[1].starts_with('-') || arg1_is_code_not_flag)
1766        && !args[1].is_empty()
1767        && args[2..].iter().all(|a| !a.starts_with('-'))
1768    {
1769        Cli {
1770            script: Some(args[1].clone()),
1771            args: if args.len() > 2 {
1772                args[2..].to_vec()
1773            } else {
1774                Vec::new()
1775            },
1776            ..Default::default()
1777        }
1778    } else {
1779        parse_cli_prelude(&args).unwrap_or_else(|| Cli::parse_from(&args))
1780    };
1781    normalize_argv_after_dash_e(&mut cli);
1782
1783    // Set global mode flags before any parsing happens.
1784    if cli.compat {
1785        crate::set_compat_mode(true);
1786    }
1787    if cli.no_interop {
1788        crate::set_no_interop_mode(true);
1789    }
1790    if cli.static_typing {
1791        crate::set_static_mode(true);
1792    }
1793
1794    if cli.help {
1795        print_cyberpunk_help();
1796        return 0;
1797    }
1798
1799    if cli.show_version {
1800        println!(
1801            "This is stryke v{} — A highly parallel Perl 5 interpreter (Rust)\n",
1802            env!("CARGO_PKG_VERSION")
1803        );
1804        println!("Built with rayon for parallel map/grep/for/sort");
1805        println!(
1806            "Threads available: {}\n",
1807            std::thread::available_parallelism()
1808                .map(|n| n.get())
1809                .unwrap_or(1)
1810        );
1811        println!(
1812            "Copyright 2026 MenkeTechnologies. Licensed under MIT.\n\n\
1813             This is free software; you can redistribute it and/or modify it\n\
1814             under the terms of the MIT License."
1815        );
1816        return 0;
1817    }
1818
1819    if let Some(ref configvar) = cli.show_config {
1820        print_config(configvar.as_deref());
1821        return 0;
1822    }
1823
1824    if let Some(code) = &cli.explain {
1825        match crate::error::explain_error(code) {
1826            Some(text) => println!("{}", text),
1827            None => {
1828                eprintln!("stryke: unknown explain code {:?}", code);
1829                crate::hosted::exit(1);
1830            }
1831        }
1832        return 0;
1833    }
1834
1835    // Configure rayon thread pool
1836    if let Some(n) = cli.threads {
1837        rayon::ThreadPoolBuilder::new()
1838            .num_threads(n)
1839            .build_global()
1840            .ok();
1841    }
1842
1843    // Multi-file execution: `st *.stk` runs all files.
1844    // Use `-j N` for parallel execution: `st -j4 *.stk`
1845    // Only triggers when ALL args are existing script files on disk.
1846    // `st file.stk ARG1 ARG2` still works because ARG1/ARG2 won't exist as files.
1847    if let Some(script) = cli.script.as_ref() {
1848        if cli.execute.is_empty()
1849            && cli.execute_features.is_empty()
1850            && !cli.line_mode
1851            && !cli.print_mode
1852            && !cli.args.is_empty()
1853        {
1854            let is_stk_ext = |p: &str| {
1855                p.ends_with(".stk") || p.ends_with(".pl") || p.ends_with(".pm") || p.ends_with(".t")
1856            };
1857            let is_existing_script = |p: &str| is_stk_ext(p) && Path::new(p).is_file();
1858
1859            if is_existing_script(script) && cli.args.iter().all(|a| is_existing_script(a)) {
1860                let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from(&args[0]));
1861                let mut all_files = vec![script.clone()];
1862                all_files.extend(cli.args.iter().cloned());
1863
1864                // Parallel execution when -j is specified
1865                let failed = if cli.threads.is_some() {
1866                    use std::sync::atomic::{AtomicUsize, Ordering};
1867                    let failed = AtomicUsize::new(0);
1868                    all_files.par_iter().for_each(|f| {
1869                        let status = process::Command::new(&exe).arg(f).status();
1870                        match status {
1871                            Ok(s) if !s.success() => {
1872                                failed.fetch_add(1, Ordering::Relaxed);
1873                            }
1874                            Err(e) => {
1875                                eprintln!("{}: {}", f, e);
1876                                failed.fetch_add(1, Ordering::Relaxed);
1877                            }
1878                            _ => {}
1879                        }
1880                    });
1881                    failed.load(Ordering::Relaxed)
1882                } else {
1883                    // Sequential execution (default)
1884                    let mut failed = 0usize;
1885                    for f in &all_files {
1886                        let status = process::Command::new(&exe).arg(f).status();
1887                        match status {
1888                            Ok(s) if !s.success() => failed += 1,
1889                            Err(e) => {
1890                                eprintln!("{}: {}", f, e);
1891                                failed += 1;
1892                            }
1893                            _ => {}
1894                        }
1895                    }
1896                    failed
1897                };
1898                crate::hosted::exit(if failed > 0 { 1 } else { 0 });
1899            }
1900        }
1901    }
1902
1903    // Check both compile-time binary name and runtime invocation name for REPL trigger.
1904    // This handles symlinks like `s` -> `stryke` and `st` -> `stryke`.
1905    let runtime_bin_name = args
1906        .first()
1907        .and_then(|p| Path::new(p).file_name())
1908        .map(|n| n.to_string_lossy().to_string())
1909        .unwrap_or_default();
1910    // Was `matches!(env!("CARGO_BIN_NAME"), …) || matches!(runtime_bin_name, …)`.
1911    // The compile-time half cannot exist in a library — and never added
1912    // anything, since the runtime half already answers the same question from
1913    // argv[0], which is also what a host passes when it dispatches `stryke` as
1914    // a builtin.
1915    let is_stryke_bin = matches!(runtime_bin_name.as_str(), "stryke" | "st" | "s");
1916    let is_repl = is_stryke_bin
1917        && cli.script.is_none()
1918        && cli.execute.is_empty()
1919        && cli.execute_features.is_empty()
1920        && !cli.line_mode
1921        && !cli.print_mode
1922        && !cli.check_only
1923        && !cli.lint
1924        && !cli.disasm
1925        && !cli.dump_ast
1926        && !cli.dump_tokens
1927        && !cli.dump_bytecode
1928        && !cli.tiers
1929        && !cli.format_source
1930        && !cli.profile
1931        && !cli.flame
1932        && !cli.dump_core
1933        && cli.explain.is_none()
1934        && io::stdin().is_terminal();
1935
1936    if is_repl {
1937        repl::run(&cli);
1938        return 0;
1939    }
1940
1941    // Determine slurp mode
1942    let slurp = cli.slurp
1943        || cli
1944            .input_separator
1945            .as_ref()
1946            .is_some_and(|v| v.as_deref() == Some("777"));
1947
1948    // Build the source code (`__DATA__` is split out before shebang / `-x` handling)
1949    let (raw_script, filename): (String, String) = if !cli.execute.is_empty() {
1950        (cli.execute.join("; "), "-e".to_string())
1951    } else if !cli.execute_features.is_empty() {
1952        (cli.execute_features.join("; "), "-E".to_string())
1953    } else if let Some(ref script) = cli.script {
1954        if script == "-" {
1955            // Like `perl -`: program text from stdin (not a file named `-` in cwd).
1956            let mut code = Vec::new();
1957            let _ = IoRead::read_to_end(&mut io::stdin(), &mut code);
1958            let code = decode_utf8_or_latin1(&code);
1959            (code, "-".to_string())
1960        } else {
1961            let script_path = if cli.path_lookup {
1962                find_in_path(script).unwrap_or_else(|| script.clone())
1963            } else {
1964                script.clone()
1965            };
1966            match read_file_text_perl_compat(&script_path) {
1967                Ok(content) => (content, script_path),
1968                Err(_) if !cli.force_script && looks_like_code(&script_path) => {
1969                    // One-liner-first: `stryke 'p 1+2'` works without `-e`
1970                    (script_path, "-e".to_string())
1971                }
1972                Err(e) => {
1973                    eprintln!("Can't open perl script \"{}\": {}", script_path, e);
1974                    crate::hosted::exit(2);
1975                }
1976            }
1977        }
1978    } else if cli.line_mode || cli.print_mode {
1979        (String::new(), "-".to_string())
1980    } else {
1981        let mut code = Vec::new();
1982        // Match `perl`: program from stdin is the full script (pipe, heredoc, or terminal until EOF).
1983        let _ = IoRead::read_to_end(&mut io::stdin(), &mut code);
1984        let code = decode_utf8_or_latin1(&code);
1985        (code, "-".to_string())
1986    };
1987
1988    let (program_text, data_opt) = crate::data_section::split_data_section(&raw_script);
1989    let code = strip_shebang_and_extract(&program_text, cli.extract.is_some());
1990
1991    // `--from-zsh`: transpile the raw source (treated as zsh) to stryke and exit,
1992    // before any Perl/stryke parsing. External commands/pipelines become
1993    // `system("...")`; unsupported constructs are reported on stderr.
1994    if cli.from_zsh {
1995        let ns = crate::zsh_convert::namespace_from_path(&filename);
1996        let (stryke_src, warnings) = crate::zsh_convert::convert_zsh(&code, &ns);
1997        for w in &warnings {
1998            eprintln!("stryke --from-zsh: {}", w);
1999        }
2000        print!("{}", stryke_src);
2001        return 0;
2002    }
2003
2004    let mut full_code = module_prelude(&cli);
2005    full_code.push_str(&code);
2006
2007    // `--dump-tokens`: run the same lexer entry `parse_with_file` uses (desugar pre-pass +
2008    // `Lexer::new_with_file(...).tokenize()`) and print each `(line, Token)` pair, then exit.
2009    if cli.dump_tokens {
2010        let desugared = if crate::compat_mode() {
2011            full_code.clone()
2012        } else {
2013            let s = crate::rust_sugar::desugar_rust_blocks(&full_code);
2014            crate::ai_sugar::desugar(&s)
2015        };
2016        let mut lexer = crate::lexer::Lexer::new_with_file(&desugared, &filename);
2017        match lexer.tokenize() {
2018            Ok(tokens) => {
2019                for (tok, line) in &tokens {
2020                    println!("{:>5}  {:?}", line, tok);
2021                }
2022            }
2023            Err(e) => {
2024                eprintln!("{}", e);
2025                crate::hosted::exit(255);
2026            }
2027        }
2028        return 0;
2029    }
2030
2031    // `--tiers`: run the program, then report which fusevm execution tier took
2032    // its chunk — asked of fusevm's own eligibility and cache predicates, so the
2033    // answer comes from the compiler that would have done the work. The
2034    // program's own output precedes the report.
2035    if cli.tiers {
2036        match crate::tiers::report(&full_code) {
2037            Ok(r) => println!("{r}"),
2038            Err(e) => {
2039                eprintln!("stryke: --tiers: {e}");
2040                crate::hosted::exit(1);
2041            }
2042        }
2043        return 0;
2044    }
2045
2046    // rkyv bytecode cache — mtime-based, skips lex/parse/compile on 2+ runs.
2047    let is_one_liner = !cli.execute.is_empty() || !cli.execute_features.is_empty();
2048    let cache_eligible = !cli.line_mode
2049        && !cli.print_mode
2050        && !cli.lint
2051        && !cli.check_only
2052        && !cli.dump_ast
2053        && !cli.dump_tokens
2054        && !cli.dump_bytecode
2055        && !cli.tiers
2056        && !cli.format_source
2057        && !cli.profile
2058        && !cli.flame
2059        && !is_one_liner
2060        && !filename.is_empty();
2061
2062    let script_path = std::path::Path::new(&filename);
2063    let cached = if cache_eligible && script_path.exists() {
2064        crate::script_cache::try_load(script_path)
2065    } else {
2066        None
2067    };
2068
2069    let (program, cached_chunk, needs_cache_save) = if let Some(c) = cached {
2070        (c.program, Some(c.chunk), false)
2071    } else {
2072        let parsed = match crate::parse_with_file(&full_code, &filename) {
2073            Ok(p) => p,
2074            Err(e) => {
2075                eprintln!("{}", e);
2076                crate::hosted::exit(255);
2077            }
2078        };
2079        (parsed, None, cache_eligible)
2080    };
2081
2082    if cli.dump_ast {
2083        match serde_json::to_string_pretty(&program) {
2084            Ok(json) => println!("{}", json),
2085            Err(e) => {
2086                eprintln!("stryke: failed to serialize AST to JSON: {}", e);
2087                crate::hosted::exit(1);
2088            }
2089        }
2090        return 0;
2091    }
2092
2093    if cli.dump_bytecode {
2094        // Compile the parsed program the same way `stryke disasm` does, but print the raw
2095        // fusevm ops (`{:#?}`) instead of the formatted `.disassemble()` view, then exit.
2096        let mut interp = VMHelper::new();
2097        if cli.no_jit {
2098            interp.vm_jit_enabled = false;
2099        }
2100        interp.set_file(&filename);
2101        if let Err(e) = crate::lint_program(&program, &mut interp) {
2102            eprintln!("{}", e);
2103            crate::hosted::exit(255);
2104        }
2105        let comp = crate::compiler::Compiler::new().with_source_file(filename.clone());
2106        match comp.compile_program(&program) {
2107            Ok(chunk) => println!("{:#?}", chunk.ops),
2108            Err(e) => {
2109                eprintln!("compile error: {:?}", e);
2110                crate::hosted::exit(1);
2111            }
2112        }
2113        return 0;
2114    }
2115
2116    if cli.format_source {
2117        // Use convert_program for clean stryke (.stk) syntax with pipes
2118        println!("{}", crate::convert::convert_program(&program));
2119        return 0;
2120    }
2121
2122    if cli.lint {
2123        let mut interp = VMHelper::new();
2124        if cli.no_jit {
2125            interp.vm_jit_enabled = false;
2126        }
2127        configure_interpreter(&cli, &mut interp, &filename);
2128        if let Some(data) = data_opt {
2129            interp.install_data_handle(data);
2130        }
2131        match crate::lint_program(&program, &mut interp) {
2132            Ok(()) => {
2133                eprintln!("{} compile OK", filename);
2134                return 0;
2135            }
2136            Err(e) => {
2137                eprintln!("{}", e);
2138                crate::hosted::exit(255);
2139            }
2140        }
2141    }
2142
2143    if cli.check_only {
2144        eprintln!("{} syntax OK", filename);
2145        return 0;
2146    }
2147
2148    if cli.dump_core {
2149        eprintln!("{} syntax OK (dump not supported)", filename);
2150        return 0;
2151    }
2152
2153    let mut interp = VMHelper::new();
2154    if cli.no_jit {
2155        interp.vm_jit_enabled = false;
2156    }
2157    if cli.disasm {
2158        interp.disasm_bytecode = true;
2159    }
2160    if cli.profile || cli.flame {
2161        interp.profiler = Some(crate::profiler::Profiler::new(filename.clone()));
2162    }
2163    // Hand the cache sidebands to the interpreter so `try_vm_execute` either runs the
2164    // pre-compiled chunk (cache hit) or saves the freshly-compiled one (cache miss).
2165    interp.cached_chunk = cached_chunk;
2166    interp.cache_script_path = if needs_cache_save {
2167        Some(script_path.to_path_buf())
2168    } else {
2169        None
2170    };
2171    configure_interpreter(&cli, &mut interp, &filename);
2172    if let Some(data) = data_opt {
2173        interp.install_data_handle(data);
2174    }
2175
2176    // --flame: when stdout is piped to a file, save real stdout for the SVG and redirect
2177    // script output to stderr so `stryke --flame x.stk > flame.svg` captures a clean SVG.
2178    // When stdout is a TTY, skip the redirect — we'll render colored bars to stderr instead.
2179    let flame_is_tty = cli.flame && io::stdout().is_terminal();
2180    #[cfg(unix)]
2181    let flame_stdout: Option<File> = if cli.flame && !flame_is_tty {
2182        use std::os::unix::io::FromRawFd;
2183        let saved = unsafe { libc::dup(1) };
2184        if saved >= 0 {
2185            unsafe { libc::dup2(2, 1) };
2186            Some(unsafe { File::from_raw_fd(saved) })
2187        } else {
2188            None
2189        }
2190    } else {
2191        None
2192    };
2193    #[cfg(not(unix))]
2194    let flame_stdout: Option<File> = None;
2195
2196    // Line processing mode (-n / -p)
2197    if cli.line_mode || cli.print_mode {
2198        if cli.line_ending.is_some() {
2199            interp.ors = "\n".to_string();
2200        }
2201
2202        // Prelude only: subs / `use` / BEGIN … INIT — main runs per line in `process_line`, not here
2203        // (stock `perl` wraps `-e` in `while (<>) { … }`, so a bare `print` must not run before input).
2204        interp.line_mode_skip_main = true;
2205        if let Err(e) = interp.execute(&program) {
2206            interp.line_mode_skip_main = false;
2207            if let Some(mut p) = interp.profiler.take() {
2208                emit_profiler_report(&mut p, &flame_stdout, flame_is_tty);
2209            }
2210            if let ErrorKind::Exit(code) = e.kind {
2211                crate::hosted::exit(code);
2212            }
2213            eprintln!("{}", e);
2214            crate::hosted::exit(255);
2215        }
2216        interp.line_mode_skip_main = false;
2217
2218        if let Err(e) = run_line_mode_loop(&cli, &mut interp, &program, slurp) {
2219            if let Some(mut p) = interp.profiler.take() {
2220                emit_profiler_report(&mut p, &flame_stdout, flame_is_tty);
2221            }
2222            if let ErrorKind::Exit(code) = e.kind {
2223                crate::hosted::exit(code);
2224            }
2225            eprintln!("{}", e);
2226            crate::hosted::exit(255);
2227        }
2228        if let Err(e) = interp.run_end_blocks() {
2229            if let Some(mut p) = interp.profiler.take() {
2230                emit_profiler_report(&mut p, &flame_stdout, flame_is_tty);
2231            }
2232            if let ErrorKind::Exit(code) = e.kind {
2233                crate::hosted::exit(code);
2234            }
2235            eprintln!("{}", e);
2236            crate::hosted::exit(255);
2237        }
2238        let _ = interp.run_global_teardown();
2239        if let Some(mut p) = interp.profiler.take() {
2240            emit_profiler_report(&mut p, &flame_stdout, flame_is_tty);
2241        }
2242    } else {
2243        // Normal execution
2244        match interp.execute(&program) {
2245            Ok(_) => {
2246                let _ = interp.run_global_teardown();
2247                let _ = io::stdout().flush();
2248                if let Some(mut p) = interp.profiler.take() {
2249                    emit_profiler_report(&mut p, &flame_stdout, flame_is_tty);
2250                }
2251                // `test_run` no longer calls `crate::hosted::exit(1)` from inside the
2252                // VM (used to make embedding impossible). It sets a flag instead;
2253                // the CLI driver translates that into the process exit code.
2254                if interp
2255                    .test_run_failed
2256                    .load(std::sync::atomic::Ordering::Relaxed)
2257                {
2258                    crate::hosted::exit(1);
2259                }
2260            }
2261            Err(e) => match e.kind {
2262                ErrorKind::Exit(code) => {
2263                    if let Some(mut p) = interp.profiler.take() {
2264                        emit_profiler_report(&mut p, &flame_stdout, flame_is_tty);
2265                    }
2266                    crate::hosted::exit(code);
2267                }
2268                ErrorKind::Die => {
2269                    if let Some(mut p) = interp.profiler.take() {
2270                        emit_profiler_report(&mut p, &flame_stdout, flame_is_tty);
2271                    }
2272                    eprint!("{}", e);
2273                    crate::hosted::exit(255);
2274                }
2275                _ => {
2276                    if let Some(mut p) = interp.profiler.take() {
2277                        emit_profiler_report(&mut p, &flame_stdout, flame_is_tty);
2278                    }
2279                    eprintln!("{}", e);
2280                    crate::hosted::exit(255);
2281                }
2282            },
2283        }
2284    }
2285
2286    // Every branch above leaves through `hosted::exit`; reaching here means
2287    // the run finished without one, which is success.
2288    0
2289}
2290
2291/// Run an [`crate::aot::EmbeddedScript`] as if it were the primary program. Minimal
2292/// `@INC` setup: current directory only — the AOT binary is meant to be self-contained, so
2293/// the target machine's `perl` (which may not exist) is not consulted. `-I` at build time
2294/// is not yet supported (v1); drop everything into the `rust { ... }` block instead.
2295fn run_embedded_script(embedded: crate::aot::EmbeddedScript, argv: Vec<String>) -> i32 {
2296    // AOT binaries don't use the rkyv cache — they're self-contained and parse/compile on every run.
2297    let program = match crate::parse_with_file(&embedded.source, &embedded.name) {
2298        Ok(p) => p,
2299        Err(e) => {
2300            eprintln!("{}", e);
2301            return 255;
2302        }
2303    };
2304    let mut interp = VMHelper::new();
2305    interp.set_file(&embedded.name);
2306    interp.program_name = embedded.name.clone();
2307    interp.argv = argv.clone();
2308    interp.scope.declare_array(
2309        "ARGV",
2310        argv.into_iter()
2311            .map(crate::value::StrykeValue::string)
2312            .collect(),
2313    );
2314    interp.scope.declare_array(
2315        "INC",
2316        vec![crate::value::StrykeValue::string(".".to_string())],
2317    );
2318    match interp.execute(&program) {
2319        Ok(_) => {
2320            let _ = interp.run_global_teardown();
2321            let _ = io::stdout().flush();
2322            // `test_run` sets this flag on assertion failure (replaces the previous
2323            // in-VM `crate::hosted::exit(1)`).
2324            if interp
2325                .test_run_failed
2326                .load(std::sync::atomic::Ordering::Relaxed)
2327            {
2328                1
2329            } else {
2330                0
2331            }
2332        }
2333        Err(e) => match e.kind {
2334            ErrorKind::Exit(code) => code,
2335            ErrorKind::Die => {
2336                eprint!("{}", e);
2337                255
2338            }
2339            _ => {
2340                eprintln!("{}", e);
2341                255
2342            }
2343        },
2344    }
2345}
2346
2347/// Run an embedded bundle (v2 AOT) — registers all bundled files as virtual modules,
2348/// then executes the entry point.
2349fn run_embedded_bundle(bundle: crate::aot::EmbeddedBundle, argv: Vec<String>) -> i32 {
2350    let entry_source = match bundle.files.get(&bundle.entry) {
2351        Some(s) => s.clone(),
2352        None => {
2353            eprintln!("stryke: bundle missing entry point: {}", bundle.entry);
2354            return 255;
2355        }
2356    };
2357
2358    let program = match crate::parse_with_file(&entry_source, &bundle.entry) {
2359        Ok(p) => p,
2360        Err(e) => {
2361            eprintln!("{}", e);
2362            return 255;
2363        }
2364    };
2365
2366    let mut interp = VMHelper::new();
2367    interp.set_file(&bundle.entry);
2368    interp.program_name = bundle.entry.clone();
2369    interp.argv = argv.clone();
2370    interp.scope.declare_array(
2371        "ARGV",
2372        argv.into_iter()
2373            .map(crate::value::StrykeValue::string)
2374            .collect(),
2375    );
2376    interp.scope.declare_array(
2377        "INC",
2378        vec![crate::value::StrykeValue::string(".".to_string())],
2379    );
2380
2381    for (path, source) in &bundle.files {
2382        interp.register_virtual_module(path.clone(), source.clone());
2383    }
2384
2385    match interp.execute(&program) {
2386        Ok(_) => {
2387            let _ = interp.run_global_teardown();
2388            let _ = io::stdout().flush();
2389            // `test_run` sets this flag on assertion failure (replaces the previous
2390            // in-VM `crate::hosted::exit(1)`).
2391            if interp
2392                .test_run_failed
2393                .load(std::sync::atomic::Ordering::Relaxed)
2394            {
2395                1
2396            } else {
2397                0
2398            }
2399        }
2400        Err(e) => match e.kind {
2401            ErrorKind::Exit(code) => code,
2402            ErrorKind::Die => {
2403                eprint!("{}", e);
2404                255
2405            }
2406            _ => {
2407                eprintln!("{}", e);
2408                255
2409            }
2410        },
2411    }
2412}
2413
2414/// `stryke BUILTIN [ARGS...]` — invoke a builtin function directly from CLI.
2415/// Hierarchy: subcommand → builtin → script. Use `--script` to force script lookup.
2416fn run_builtin_subcommand(name: &str, argv: &[String]) -> i32 {
2417    use crate::value::StrykeValue;
2418    use crate::vm_helper::VMHelper;
2419
2420    let mut interp = VMHelper::new();
2421    let argv_values: Vec<StrykeValue> = argv
2422        .iter()
2423        .map(|s| StrykeValue::string(s.clone()))
2424        .collect();
2425    let _ = interp.scope.set_array("ARGV", argv_values);
2426
2427    // Wrap in `p(...)` to print the result — most builtins return values without printing.
2428    // Exceptions like `pin`/`fire_and_forget` loop forever and print to stderr anyway.
2429    // Ref-returning reflection builtins (`perfview`, …) wrap in `ddump` first so the
2430    // CLI shows the data structure rather than the `ARRAY(0x...)` / `HASH(0x...)` pointer.
2431    let wrap_dd = matches!(name, "perfview" | "pfv");
2432    let inner = if argv.is_empty() {
2433        format!("{}()", name)
2434    } else {
2435        format!("{}(@ARGV)", name)
2436    };
2437    let code = if wrap_dd {
2438        format!("p ddump({})", inner)
2439    } else {
2440        format!("p {}", inner)
2441    };
2442
2443    let program = match crate::parse_with_file(&code, "-e") {
2444        Ok(p) => p,
2445        Err(e) => {
2446            eprintln!("{}", e);
2447            return 255;
2448        }
2449    };
2450
2451    match interp.execute(&program) {
2452        Ok(_) => {
2453            let _ = interp.run_global_teardown();
2454            let _ = io::stdout().flush();
2455            // `test_run` sets this flag on assertion failure (replaces the previous
2456            // in-VM `crate::hosted::exit(1)`).
2457            if interp
2458                .test_run_failed
2459                .load(std::sync::atomic::Ordering::Relaxed)
2460            {
2461                1
2462            } else {
2463                0
2464            }
2465        }
2466        Err(e) => match e.kind {
2467            ErrorKind::Exit(code) => code,
2468            ErrorKind::Die => {
2469                eprint!("{}", e);
2470                255
2471            }
2472            _ => {
2473                eprintln!("{}", e);
2474                255
2475            }
2476        },
2477    }
2478}
2479
2480/// `stryke check FILE...` — parse + compile without executing.
2481/// Reports errors with file:line:col format suitable for CI and editor integration.
2482fn run_check_subcommand(args: &[String]) -> i32 {
2483    if args.is_empty() {
2484        eprintln!("usage: stryke check FILE...");
2485        eprintln!();
2486        eprintln!("Parse and compile stryke/perl files without executing.");
2487        eprintln!("Reports warnings and errors with file:line:col format.");
2488        eprintln!();
2489        eprintln!("Options:");
2490        eprintln!("  -q, --quiet    Only output errors, no success messages");
2491        eprintln!("  --json         Output diagnostics as JSON (one object per line)");
2492        return 0;
2493    }
2494
2495    let mut files: Vec<String> = Vec::new();
2496    let mut quiet = false;
2497    let mut json_output = false;
2498    let mut i = 0;
2499    while i < args.len() {
2500        match args[i].as_str() {
2501            "-q" | "--quiet" => quiet = true,
2502            "--json" => json_output = true,
2503            "-h" | "--help" => {
2504                eprintln!("usage: stryke check FILE...");
2505                return 0;
2506            }
2507            s if !s.starts_with('-') => files.push(s.to_string()),
2508            other => {
2509                eprintln!("stryke check: unknown option: {}", other);
2510                return 2;
2511            }
2512        }
2513        i += 1;
2514    }
2515
2516    if files.is_empty() {
2517        eprintln!("stryke check: no files specified");
2518        return 2;
2519    }
2520
2521    let mut errors = 0;
2522    for file in &files {
2523        let source = match std::fs::read_to_string(file) {
2524            Ok(s) => s,
2525            Err(e) => {
2526                if json_output {
2527                    println!(
2528                        r#"{{"file":"{}","line":0,"col":0,"severity":"error","message":"{}"}}"#,
2529                        file,
2530                        e.to_string().replace('"', "\\\"")
2531                    );
2532                } else {
2533                    eprintln!("{}:0:0: error: {}", file, e);
2534                }
2535                errors += 1;
2536                continue;
2537            }
2538        };
2539
2540        let program = match crate::parse_with_file(&source, file) {
2541            Ok(p) => p,
2542            Err(e) => {
2543                if json_output {
2544                    println!(
2545                        r#"{{"file":"{}","line":{},"col":0,"severity":"error","message":"{}"}}"#,
2546                        file,
2547                        e.line,
2548                        e.to_string().replace('"', "\\\"").replace('\n', "\\n")
2549                    );
2550                } else {
2551                    eprintln!("{}:{}:0: error: {}", file, e.line, e);
2552                }
2553                errors += 1;
2554                continue;
2555            }
2556        };
2557
2558        let mut interp = VMHelper::new();
2559        interp.set_file(file);
2560        match crate::lint_program(&program, &mut interp) {
2561            Ok(()) => {
2562                if !quiet && !json_output {
2563                    eprintln!("{}: OK", file);
2564                }
2565            }
2566            Err(e) => {
2567                if json_output {
2568                    println!(
2569                        r#"{{"file":"{}","line":{},"col":0,"severity":"error","message":"{}"}}"#,
2570                        file,
2571                        e.line,
2572                        e.to_string().replace('"', "\\\"").replace('\n', "\\n")
2573                    );
2574                } else {
2575                    eprintln!("{}:{}:0: error: {}", file, e.line, e);
2576                }
2577                errors += 1;
2578            }
2579        }
2580    }
2581
2582    if errors > 0 {
2583        if !quiet && !json_output {
2584            eprintln!();
2585            eprintln!(
2586                "{} error{} in {} file{}",
2587                errors,
2588                if errors == 1 { "" } else { "s" },
2589                files.len(),
2590                if files.len() == 1 { "" } else { "s" }
2591            );
2592        }
2593        1
2594    } else {
2595        if !quiet && !json_output && files.len() > 1 {
2596            eprintln!();
2597            eprintln!("All {} files OK", files.len());
2598        }
2599        0
2600    }
2601}
2602
2603/// `stryke disasm FILE` — disassemble bytecode.
2604fn run_disasm_subcommand(args: &[String]) -> i32 {
2605    let mut file: Option<String> = None;
2606    let mut show_jit = false;
2607    let mut i = 0;
2608    while i < args.len() {
2609        match args[i].as_str() {
2610            "--jit" => show_jit = true,
2611            "-h" | "--help" => {
2612                println!("usage: stryke disasm [--jit] FILE");
2613                println!();
2614                println!("Disassemble stryke bytecode for a file.");
2615                println!();
2616                println!("Options:");
2617                println!("  --jit    Also show Cranelift IR (when JIT is enabled)");
2618                return 0;
2619            }
2620            s if !s.starts_with('-') && file.is_none() => file = Some(s.to_string()),
2621            other => {
2622                eprintln!("stryke disasm: unknown option: {}", other);
2623                return 2;
2624            }
2625        }
2626        i += 1;
2627    }
2628
2629    let Some(file) = file else {
2630        eprintln!("usage: stryke disasm [--jit] FILE");
2631        return 2;
2632    };
2633
2634    let source = match std::fs::read_to_string(&file) {
2635        Ok(s) => s,
2636        Err(e) => {
2637            eprintln!("stryke disasm: {}: {}", file, e);
2638            return 1;
2639        }
2640    };
2641
2642    let program = match crate::parse_with_file(&source, &file) {
2643        Ok(p) => p,
2644        Err(e) => {
2645            eprintln!("{}", e);
2646            return 1;
2647        }
2648    };
2649
2650    let mut interp = VMHelper::new();
2651    interp.set_file(&file);
2652    if let Err(e) = crate::lint_program(&program, &mut interp) {
2653        eprintln!("{}", e);
2654        return 1;
2655    }
2656
2657    let comp = crate::compiler::Compiler::new().with_source_file(file.clone());
2658    let chunk = match comp.compile_program(&program) {
2659        Ok(c) => c,
2660        Err(e) => {
2661            eprintln!("compile error: {:?}", e);
2662            return 1;
2663        }
2664    };
2665
2666    println!("=== Bytecode for {} ===", file);
2667    println!("{}", chunk.disassemble());
2668
2669    if show_jit {
2670        println!();
2671        println!("=== Cranelift IR ===");
2672        println!("(JIT IR dump not yet implemented)");
2673    }
2674
2675    0
2676}
2677
2678/// `stryke prun FILE...` — run multiple files in parallel.
2679fn run_prun_subcommand(exe_arg: &str, args: &[String]) -> i32 {
2680    if args.is_empty() || args[0] == "-h" || args[0] == "--help" {
2681        println!("usage: stryke prun FILE...");
2682        println!();
2683        println!("Run multiple stryke files in parallel using all available cores.");
2684        println!();
2685        println!("Examples:");
2686        println!("  stryke prun *.stk              # run all .stk files in parallel");
2687        println!("  stryke prun a.stk b.stk c.stk  # run specific files in parallel");
2688        println!();
2689        println!("For sequential execution, use: stryke *.stk");
2690        println!("For parallel with thread limit: stryke -j4 *.stk");
2691        return if args.is_empty() { 2 } else { 0 };
2692    }
2693
2694    let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from(exe_arg));
2695    let files: Vec<&String> = args.iter().filter(|a| !a.starts_with('-')).collect();
2696
2697    if files.is_empty() {
2698        eprintln!("stryke prun: no files specified");
2699        return 2;
2700    }
2701
2702    use std::sync::atomic::{AtomicUsize, Ordering};
2703    let failed = AtomicUsize::new(0);
2704    let total = files.len();
2705
2706    eprintln!(
2707        "\x1b[36mRunning {} file{} in parallel\x1b[0m",
2708        total,
2709        if total == 1 { "" } else { "s" }
2710    );
2711
2712    files.par_iter().for_each(|f| {
2713        let status = process::Command::new(&exe)
2714            .arg(f)
2715            .stdout(process::Stdio::inherit())
2716            .stderr(process::Stdio::inherit())
2717            .status();
2718        match status {
2719            Ok(s) if !s.success() => {
2720                failed.fetch_add(1, Ordering::Relaxed);
2721            }
2722            Err(e) => {
2723                eprintln!("{}: {}", f, e);
2724                failed.fetch_add(1, Ordering::Relaxed);
2725            }
2726            _ => {}
2727        }
2728    });
2729
2730    let failed_count = failed.load(Ordering::Relaxed);
2731    if failed_count > 0 {
2732        eprintln!("\x1b[31m✗ {} of {} failed\x1b[0m", failed_count, total);
2733        1
2734    } else {
2735        eprintln!("\x1b[32m✓ All {} completed\x1b[0m", total);
2736        0
2737    }
2738}
2739
2740/// `stryke profile FILE` — run with profiling and output structured data.
2741fn run_profile_subcommand(exe_arg: &str, args: &[String]) -> i32 {
2742    let mut file: Option<String> = None;
2743    let mut output: Option<String> = None;
2744    let mut flame = false;
2745    let mut json = false;
2746    let mut i = 0;
2747    while i < args.len() {
2748        match args[i].as_str() {
2749            "-o" | "--output" => {
2750                i += 1;
2751                if i >= args.len() {
2752                    eprintln!("stryke profile: -o requires an argument");
2753                    return 2;
2754                }
2755                output = Some(args[i].clone());
2756            }
2757            "--flame" => flame = true,
2758            "--json" => json = true,
2759            "-h" | "--help" => {
2760                println!("usage: stryke profile [OPTIONS] FILE");
2761                println!();
2762                println!("Run a file with profiling enabled and output structured data.");
2763                println!();
2764                println!("Options:");
2765                println!("  -o, --output FILE   Write output to FILE instead of stdout/stderr");
2766                println!("  --flame             Generate flamegraph SVG");
2767                println!("  --json              Output profile data as JSON");
2768                return 0;
2769            }
2770            s if !s.starts_with('-') && file.is_none() => file = Some(s.to_string()),
2771            other => {
2772                eprintln!("stryke profile: unknown option: {}", other);
2773                return 2;
2774            }
2775        }
2776        i += 1;
2777    }
2778
2779    let Some(file) = file else {
2780        eprintln!("usage: stryke profile [OPTIONS] FILE");
2781        return 2;
2782    };
2783
2784    let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from(exe_arg));
2785    let mut cmd = process::Command::new(exe);
2786
2787    if flame {
2788        cmd.arg("--flame");
2789    } else {
2790        cmd.arg("--profile");
2791    }
2792    cmd.arg(&file);
2793
2794    if let Some(ref out) = output {
2795        if flame {
2796            let out_file = match std::fs::File::create(out) {
2797                Ok(f) => f,
2798                Err(e) => {
2799                    eprintln!("stryke profile: cannot create {}: {}", out, e);
2800                    return 1;
2801                }
2802            };
2803            cmd.stdout(out_file);
2804        }
2805    }
2806
2807    let status = match cmd.status() {
2808        Ok(s) => s,
2809        Err(e) => {
2810            eprintln!("stryke profile: {}", e);
2811            return 1;
2812        }
2813    };
2814
2815    if json && !flame {
2816        eprintln!("(--json profile output not yet implemented; use --flame -o file.svg for structured output)");
2817    }
2818
2819    status.code().unwrap_or(1)
2820}
2821
2822/// `stryke completions [SHELL]` — emit shell completions.
2823fn run_completions_subcommand(args: &[String]) -> i32 {
2824    let shell = args.first().map(|s| s.as_str()).unwrap_or("");
2825    match shell {
2826        "zsh" | "" => {
2827            let completions = include_str!("../completions/_stryke");
2828            println!("{}", completions);
2829            0
2830        }
2831        "-h" | "--help" => {
2832            println!("usage: stryke completions [SHELL]");
2833            println!();
2834            println!("Emit shell completions to stdout.");
2835            println!();
2836            println!("Supported shells:");
2837            println!("  zsh   (default)");
2838            println!();
2839            println!("Examples:");
2840            println!("  stryke completions zsh > /usr/local/share/zsh/site-functions/_stryke");
2841            println!("  stryke completions >> ~/.zshrc");
2842            0
2843        }
2844        other => {
2845            eprintln!("stryke completions: unsupported shell: {}", other);
2846            eprintln!("Supported: zsh");
2847            2
2848        }
2849    }
2850}
2851
2852/// `stryke ast FILE` — dump AST as JSON.
2853fn run_ast_subcommand(args: &[String]) -> i32 {
2854    if args.is_empty() || args[0] == "-h" || args[0] == "--help" {
2855        println!("usage: stryke ast FILE");
2856        println!();
2857        println!("Parse a file and dump the AST as JSON to stdout.");
2858        return if args.is_empty() { 2 } else { 0 };
2859    }
2860
2861    let file = &args[0];
2862    let source = match std::fs::read_to_string(file) {
2863        Ok(s) => s,
2864        Err(e) => {
2865            eprintln!("{}: {}", file, e);
2866            return 1;
2867        }
2868    };
2869
2870    let program = match crate::parse_with_file(&source, file) {
2871        Ok(p) => p,
2872        Err(e) => {
2873            eprintln!("{}", e);
2874            return 1;
2875        }
2876    };
2877
2878    match serde_json::to_string_pretty(&program) {
2879        Ok(json) => {
2880            println!("{}", json);
2881            0
2882        }
2883        Err(e) => {
2884            eprintln!("stryke ast: failed to serialize: {}", e);
2885            1
2886        }
2887    }
2888}
2889
2890/// `stryke gen-docs [PATH] [--out DIR]` — walk a directory, find every
2891/// `.stk` / `.pl` / `.pm` source file, generate Markdown module docs
2892/// for each, and write them under `--out DIR` (default: `docs/`).
2893/// Output layout mirrors the source layout: `lib/foo.stk` →
2894/// `docs/lib/foo.md`. Also writes an `index.md` summarizing the
2895/// modules processed.
2896fn run_gen_docs_subcommand(args: &[String]) -> i32 {
2897    if args.first().map(|s| s.as_str()) == Some("-h")
2898        || args.first().map(|s| s.as_str()) == Some("--help")
2899    {
2900        println!("usage: stryke gen-docs [PATH] [--out DIR]");
2901        println!();
2902        println!("Walk PATH (default `.`) for `.stk`, `.pl`, `.pm` sources and");
2903        println!("generate Markdown module docs for each. Output goes under");
2904        println!("--out DIR (default `docs/`), mirroring the source layout.");
2905        println!();
2906        println!("Each output file contains the same content produced by");
2907        println!("`stryke --gen-docs FILE` on a single source.");
2908        return 0;
2909    }
2910
2911    // Parse args: optional positional PATH, optional `--out DIR`.
2912    let mut path: Option<String> = None;
2913    let mut out_dir: String = "docs".to_string();
2914    let mut i = 0;
2915    while i < args.len() {
2916        match args[i].as_str() {
2917            "--out" | "-o" => {
2918                if i + 1 >= args.len() {
2919                    eprintln!("stryke gen-docs: --out requires a directory argument");
2920                    return 2;
2921                }
2922                out_dir = args[i + 1].clone();
2923                i += 2;
2924            }
2925            other if !other.starts_with('-') && path.is_none() => {
2926                path = Some(other.to_string());
2927                i += 1;
2928            }
2929            other => {
2930                eprintln!("stryke gen-docs: unexpected argument: {other}");
2931                return 2;
2932            }
2933        }
2934    }
2935    let root = std::path::PathBuf::from(path.unwrap_or_else(|| ".".to_string()));
2936
2937    if !root.exists() {
2938        eprintln!("stryke gen-docs: path does not exist: {}", root.display());
2939        return 1;
2940    }
2941
2942    let mut sources: Vec<std::path::PathBuf> = Vec::new();
2943    collect_doc_sources(&root, &mut sources);
2944    sources.sort();
2945
2946    if sources.is_empty() {
2947        eprintln!(
2948            "stryke gen-docs: no `.stk` / `.pl` / `.pm` files found under {}",
2949            root.display()
2950        );
2951        return 1;
2952    }
2953
2954    let out_root = std::path::PathBuf::from(&out_dir);
2955    if let Err(e) = std::fs::create_dir_all(&out_root) {
2956        eprintln!(
2957            "stryke gen-docs: cannot create output dir {}: {}",
2958            out_root.display(),
2959            e
2960        );
2961        return 1;
2962    }
2963
2964    let mut index: Vec<(String, String)> = Vec::new(); // (module title, relative md path)
2965    let mut errors: Vec<String> = Vec::new();
2966    for src in &sources {
2967        let source = match std::fs::read_to_string(src) {
2968            Ok(s) => s,
2969            Err(e) => {
2970                errors.push(format!("{}: {}", src.display(), e));
2971                continue;
2972            }
2973        };
2974        let program = match crate::parse_with_file(&source, &src.to_string_lossy()) {
2975            Ok(p) => p,
2976            Err(e) => {
2977                errors.push(format!("{}: parse error: {}", src.display(), e));
2978                continue;
2979            }
2980        };
2981        let md = crate::docs::generate_markdown(&src.to_string_lossy(), &source, &program);
2982
2983        // Output path: out_root / (src relative to root, with .md suffix).
2984        let rel = src.strip_prefix(&root).unwrap_or(src);
2985        let mut out_path = out_root.join(rel);
2986        out_path.set_extension("md");
2987        if let Some(parent) = out_path.parent() {
2988            if let Err(e) = std::fs::create_dir_all(parent) {
2989                errors.push(format!("{}: mkdir failed: {}", parent.display(), e));
2990                continue;
2991            }
2992        }
2993        if let Err(e) = std::fs::write(&out_path, &md) {
2994            errors.push(format!("{}: write failed: {}", out_path.display(), e));
2995            continue;
2996        }
2997
2998        // Pull the module title from the first line of the generated
2999        // Markdown (`# Module: <title>`).
3000        let title = md
3001            .lines()
3002            .next()
3003            .and_then(|l| l.strip_prefix("# Module: "))
3004            .unwrap_or_else(|| {
3005                src.file_stem()
3006                    .and_then(|s| s.to_str())
3007                    .unwrap_or("(unknown)")
3008            })
3009            .to_string();
3010        let rel_md = out_path
3011            .strip_prefix(&out_root)
3012            .unwrap_or(&out_path)
3013            .to_string_lossy()
3014            .into_owned();
3015        index.push((title, rel_md));
3016        println!("{}", out_path.display());
3017    }
3018
3019    // index.md summarizing the generated docs.
3020    let mut idx = String::new();
3021    idx.push_str("# Module index\n\n");
3022    for (title, rel) in &index {
3023        idx.push_str(&format!("- [{title}]({rel})\n"));
3024    }
3025    let idx_path = out_root.join("index.md");
3026    if let Err(e) = std::fs::write(&idx_path, idx) {
3027        errors.push(format!("{}: write failed: {}", idx_path.display(), e));
3028    } else {
3029        println!("{}", idx_path.display());
3030    }
3031
3032    if !errors.is_empty() {
3033        eprintln!("stryke gen-docs: {} error(s):", errors.len());
3034        for e in &errors {
3035            eprintln!("  {e}");
3036        }
3037        return 1;
3038    }
3039    0
3040}
3041
3042/// Recursively collect stryke source files under `dir`, skipping
3043/// directories that are conventionally not source (`.git`, `target`,
3044/// `node_modules`, `docs`, `.cargo`, etc.).
3045fn collect_doc_sources(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
3046    const SKIP: &[&str] = &[
3047        ".git",
3048        "target",
3049        "node_modules",
3050        ".cargo",
3051        ".idea",
3052        ".vscode",
3053        "build",
3054        "dist",
3055    ];
3056    let Ok(entries) = std::fs::read_dir(dir) else {
3057        return;
3058    };
3059    for entry in entries.flatten() {
3060        let path = entry.path();
3061        if path.is_dir() {
3062            if let Some(name) = path.file_name().and_then(|s| s.to_str()) {
3063                if SKIP.contains(&name) || name.starts_with('.') {
3064                    continue;
3065                }
3066            }
3067            collect_doc_sources(&path, out);
3068        } else if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
3069            if matches!(ext, "stk" | "pl" | "pm") {
3070                out.push(path);
3071            }
3072        }
3073    }
3074}
3075
3076/// `stryke build SCRIPT [-o OUT]` or `stryke build --project DIR [-o OUT]`
3077/// Compile a Perl script (or project with lib/) into a standalone binary.
3078/// `stryke ai PROMPT [--model NAME] [--system TEXT] [--stream]` — quick
3079/// CLI access. With no PROMPT, reads it from stdin. Output goes to
3080/// stdout. Errors surface on stderr with exit 1.
3081///
3082/// Modal flags switch to specialized pipelines:
3083///   --image       image generation (DALL-E 3 / gpt-image-1)
3084///   --transcribe  Whisper transcription (PROMPT becomes the audio path)
3085///   --speak       OpenAI TTS (PROMPT becomes the spoken text)
3086fn run_ai_subcommand(args: &[String]) -> i32 {
3087    use std::io::Read;
3088    let mut prompt: Option<String> = None;
3089    let mut model: Option<String> = None;
3090    let mut system: Option<String> = None;
3091    let mut stream = false;
3092    let mut json_out = false;
3093    let mut mode_image = false;
3094    let mut mode_transcribe = false;
3095    let mut mode_speak = false;
3096    let mut output: Option<String> = None;
3097    let mut size: Option<String> = None;
3098    let mut voice: Option<String> = None;
3099    let mut quality: Option<String> = None;
3100    let mut language: Option<String> = None;
3101
3102    let mut i = 0;
3103    while i < args.len() {
3104        match args[i].as_str() {
3105            "--model" if i + 1 < args.len() => {
3106                model = Some(args[i + 1].clone());
3107                i += 2;
3108            }
3109            "--system" if i + 1 < args.len() => {
3110                system = Some(args[i + 1].clone());
3111                i += 2;
3112            }
3113            "--stream" => {
3114                stream = true;
3115                i += 1;
3116            }
3117            "--json" => {
3118                json_out = true;
3119                i += 1;
3120            }
3121            "--image" => {
3122                mode_image = true;
3123                i += 1;
3124            }
3125            "--transcribe" => {
3126                mode_transcribe = true;
3127                i += 1;
3128            }
3129            "--speak" => {
3130                mode_speak = true;
3131                i += 1;
3132            }
3133            "-o" | "--output" if i + 1 < args.len() => {
3134                output = Some(args[i + 1].clone());
3135                i += 2;
3136            }
3137            "--size" if i + 1 < args.len() => {
3138                size = Some(args[i + 1].clone());
3139                i += 2;
3140            }
3141            "--voice" if i + 1 < args.len() => {
3142                voice = Some(args[i + 1].clone());
3143                i += 2;
3144            }
3145            "--quality" if i + 1 < args.len() => {
3146                quality = Some(args[i + 1].clone());
3147                i += 2;
3148            }
3149            "--language" if i + 1 < args.len() => {
3150                language = Some(args[i + 1].clone());
3151                i += 2;
3152            }
3153            "--help" | "-h" => {
3154                println!(
3155                    "Usage: stryke ai [PROMPT] [FLAGS]\n\
3156                     \n\
3157                     Modes (mutually exclusive — default is text completion):\n\
3158                       <none>          chat completion → stdout\n\
3159                       --image         text-to-image (DALL-E 3 / gpt-image-1)\n\
3160                       --transcribe    Whisper transcription (PROMPT = audio path)\n\
3161                       --speak         OpenAI TTS (PROMPT = text to speak)\n\
3162                     \n\
3163                     Common flags:\n\
3164                       --model MODEL   override the model (default from stryke.toml)\n\
3165                       --system TEXT   set the system prompt (chat mode)\n\
3166                       --stream        stream tokens to stdout (chat mode)\n\
3167                       --json          emit a JSON object with usd/tokens/response\n\
3168                       -o, --output    write binary output to a path (image/speak)\n\
3169                     \n\
3170                     Image flags:    --size 1024x1024  --quality hd  -o out.png\n\
3171                     Speak flags:    --voice alloy     -o out.mp3\n\
3172                     Transcribe:     --language en"
3173                );
3174                return 0;
3175            }
3176            other if !other.starts_with("--") => {
3177                let mut combined = other.to_string();
3178                i += 1;
3179                while i < args.len() && !args[i].starts_with("--") {
3180                    combined.push(' ');
3181                    combined.push_str(&args[i]);
3182                    i += 1;
3183                }
3184                prompt = Some(combined);
3185            }
3186            other => {
3187                eprintln!("stryke ai: unknown flag `{}`", other);
3188                return 2;
3189            }
3190        }
3191    }
3192
3193    let mode_count = [mode_image, mode_transcribe, mode_speak]
3194        .iter()
3195        .filter(|b| **b)
3196        .count();
3197    if mode_count > 1 {
3198        eprintln!("stryke ai: --image / --transcribe / --speak are mutually exclusive");
3199        return 2;
3200    }
3201
3202    if mode_image {
3203        return run_ai_image_cli(prompt, model, output, size, quality);
3204    }
3205    if mode_transcribe {
3206        return run_ai_transcribe_cli(prompt, model, output, language);
3207    }
3208    if mode_speak {
3209        return run_ai_speak_cli(prompt, model, output, voice);
3210    }
3211
3212    if prompt.is_none() {
3213        let mut buf = String::new();
3214        if std::io::stdin().read_to_string(&mut buf).is_err() {
3215            eprintln!("stryke ai: failed to read prompt from stdin");
3216            return 1;
3217        }
3218        let trimmed = buf.trim().to_string();
3219        if trimmed.is_empty() {
3220            eprintln!("stryke ai: no prompt given (argv or stdin)");
3221            return 1;
3222        }
3223        prompt = Some(trimmed);
3224    }
3225    let prompt = prompt.unwrap();
3226
3227    // Build a stryke -e snippet — keeps the AI plumbing identical to
3228    // every other code path. Cheaper than re-implementing config
3229    // parsing in the CLI.
3230    let mut script = String::new();
3231    if stream {
3232        script.push_str(
3233            "my $state = +{ buf => \"\" };\n\
3234             my %opts = ( on_chunk => sub { print $_[0]; STDOUT->flush } );\n",
3235        );
3236    } else {
3237        script.push_str("my %opts = ();\n");
3238    }
3239    if let Some(m) = &model {
3240        script.push_str(&format!("$opts{{model}} = q{{{}}};\n", m));
3241    }
3242    if let Some(s) = &system {
3243        script.push_str(&format!("$opts{{system}} = q{{{}}};\n", s));
3244    }
3245    if stream {
3246        script.push_str("my $resp = stream_prompt(do { local $_ = q{__PROMPT__}; $_ }, %opts);\n");
3247    } else {
3248        script.push_str("my $resp = ai(do { local $_ = q{__PROMPT__}; $_ }, %opts);\n");
3249    }
3250    if json_out {
3251        script.push_str(
3252            "my $c = ai_cost();\n\
3253             use JSON ();\n\
3254             print JSON::encode_json(+{ response => $resp, usd => $c->{usd}, input_tokens => $c->{input_tokens}, output_tokens => $c->{output_tokens} });\n\
3255             print \"\\n\";\n",
3256        );
3257    } else if !stream {
3258        script.push_str("print $resp, \"\\n\";\n");
3259    } else {
3260        script.push_str("print \"\\n\";\n");
3261    }
3262    let final_script = script.replace(
3263        "__PROMPT__",
3264        &prompt.replace('\\', "\\\\").replace('}', "\\}"),
3265    );
3266
3267    let mut interp = VMHelper::new();
3268    match crate::parse_and_run_string(&final_script, &mut interp) {
3269        Ok(_) => 0,
3270        Err(e) => {
3271            eprintln!("stryke ai: {}", e);
3272            1
3273        }
3274    }
3275}
3276
3277/// `stryke ai --image PROMPT --output FILE` — runs `ai_image` from CLI.
3278/// When `--output` is omitted, prints the base64 PNG to stdout.
3279fn run_ai_image_cli(
3280    prompt: Option<String>,
3281    model: Option<String>,
3282    output: Option<String>,
3283    size: Option<String>,
3284    quality: Option<String>,
3285) -> i32 {
3286    let prompt = match prompt {
3287        Some(p) => p,
3288        None => {
3289            eprintln!("stryke ai --image: PROMPT required");
3290            return 2;
3291        }
3292    };
3293    let mut script = String::from("my %opts;\n");
3294    if let Some(m) = model {
3295        script.push_str(&format!("$opts{{model}} = q{{{}}};\n", m));
3296    }
3297    if let Some(s) = size {
3298        script.push_str(&format!("$opts{{size}} = q{{{}}};\n", s));
3299    }
3300    if let Some(q) = quality {
3301        script.push_str(&format!("$opts{{quality}} = q{{{}}};\n", q));
3302    }
3303    if let Some(o) = &output {
3304        script.push_str(&format!("$opts{{output}} = q{{{}}};\n", o));
3305        script.push_str(&format!(
3306            "my $bytes = ai_image(q{{{}}}, %opts);\nprint STDERR \"wrote \", q{{{}}}, \" (\", length($bytes), \" bytes)\\n\";\n",
3307            prompt.replace('\\', "\\\\").replace('}', "\\}"),
3308            o
3309        ));
3310    } else {
3311        // No output path — emit base64 to stdout so it's pipeable.
3312        script.push_str(&format!(
3313            "my $bytes = ai_image(q{{{}}}, %opts);\nuse MIME::Base64 ();\nprint MIME::Base64::encode_base64($bytes);\n",
3314            prompt.replace('\\', "\\\\").replace('}', "\\}")
3315        ));
3316    }
3317    run_ai_cli_script(&script)
3318}
3319
3320/// `stryke ai --transcribe PATH [--output OUT.txt] [--language en]`
3321fn run_ai_transcribe_cli(
3322    prompt: Option<String>,
3323    model: Option<String>,
3324    output: Option<String>,
3325    language: Option<String>,
3326) -> i32 {
3327    let path = match prompt {
3328        Some(p) => p,
3329        None => {
3330            eprintln!("stryke ai --transcribe: PATH to audio file required");
3331            return 2;
3332        }
3333    };
3334    let mut script = String::from("my %opts;\n");
3335    if let Some(m) = model {
3336        script.push_str(&format!("$opts{{model}} = q{{{}}};\n", m));
3337    }
3338    if let Some(l) = language {
3339        script.push_str(&format!("$opts{{language}} = q{{{}}};\n", l));
3340    }
3341    script.push_str(&format!(
3342        "my $text = ai_transcribe(q{{{}}}, %opts);\n",
3343        path.replace('\\', "\\\\").replace('}', "\\}"),
3344    ));
3345    if let Some(o) = output {
3346        script.push_str(&format!(
3347            "open my $fh, '>', q{{{}}} or die qq{{open $!: }};\nprint $fh $text;\nclose $fh;\nprint STDERR qq{{wrote }}, q{{{}}}, qq{{ (}}, length($text), qq{{ chars)\\n}};\n",
3348            o, o
3349        ));
3350    } else {
3351        script.push_str("print $text, \"\\n\";\n");
3352    }
3353    run_ai_cli_script(&script)
3354}
3355
3356/// `stryke ai --speak TEXT [--output OUT.mp3] [--voice alloy]`
3357fn run_ai_speak_cli(
3358    prompt: Option<String>,
3359    model: Option<String>,
3360    output: Option<String>,
3361    voice: Option<String>,
3362) -> i32 {
3363    let text = match prompt {
3364        Some(p) => p,
3365        None => {
3366            eprintln!("stryke ai --speak: TEXT required");
3367            return 2;
3368        }
3369    };
3370    let mut script = String::from("my %opts;\n");
3371    if let Some(m) = model {
3372        script.push_str(&format!("$opts{{model}} = q{{{}}};\n", m));
3373    }
3374    if let Some(v) = voice {
3375        script.push_str(&format!("$opts{{voice}} = q{{{}}};\n", v));
3376    }
3377    let out_path = output.unwrap_or_else(|| "speech.mp3".to_string());
3378    script.push_str(&format!("$opts{{output}} = q{{{}}};\n", out_path));
3379    script.push_str(&format!(
3380        "my $bytes = ai_speak(q{{{}}}, %opts);\nprint STDERR qq{{wrote }}, q{{{}}}, qq{{ (}}, length($bytes), qq{{ bytes)\\n}};\n",
3381        text.replace('\\', "\\\\").replace('}', "\\}"),
3382        out_path
3383    ));
3384    run_ai_cli_script(&script)
3385}
3386
3387/// Shared script-driver for the three modal `stryke ai` CLI helpers.
3388fn run_ai_cli_script(script: &str) -> i32 {
3389    let mut interp = VMHelper::new();
3390    match crate::parse_and_run_string(script, &mut interp) {
3391        Ok(_) => 0,
3392        Err(e) => {
3393            eprintln!("stryke ai: {}", e);
3394            1
3395        }
3396    }
3397}
3398
3399fn run_build_subcommand(args: &[String]) -> i32 {
3400    let mut script: Option<String> = None;
3401    let mut project_dir: Option<String> = None;
3402    let mut out: Option<String> = None;
3403    let mut mcp_server = false;
3404    let mut native = false;
3405    let mut i = 0usize;
3406    while i < args.len() {
3407        match args[i].as_str() {
3408            "--native" | "-n" => {
3409                native = true;
3410            }
3411            "-o" | "--output" => {
3412                i += 1;
3413                if i >= args.len() {
3414                    eprintln!("stryke build: -o requires an argument");
3415                    return 2;
3416                }
3417                out = Some(args[i].clone());
3418            }
3419            "--project" | "-p" => {
3420                i += 1;
3421                if i >= args.len() {
3422                    eprintln!("stryke build: --project requires a directory argument");
3423                    return 2;
3424                }
3425                project_dir = Some(args[i].clone());
3426            }
3427            "--mcp-server" => {
3428                mcp_server = true;
3429            }
3430            "-h" | "--help" => {
3431                println!("usage: stryke build SCRIPT [-o OUTPUT] [--mcp-server]");
3432                println!("       stryke build --project DIR [-o OUTPUT] [--mcp-server]");
3433                println!();
3434                println!(
3435                    "Compile a Perl script into a standalone executable binary. The output is"
3436                );
3437                println!(
3438                    "a copy of this stryke binary with the script source embedded as a compressed"
3439                );
3440                println!(
3441                    "trailer. `scp` the result to any compatible machine and run it directly —"
3442                );
3443                println!("no perl, no stryke, no @INC setup required.");
3444                println!();
3445                println!("Options:");
3446                println!(
3447                    "  --native, -n     Compile to native machine code via fusevm's Cranelift"
3448                );
3449                println!(
3450                    "                   AOT (no embedded source). Covers the arithmetic/string/"
3451                );
3452                println!(
3453                    "                   scalar/array/hash/print subset; needs libstryke.a beside"
3454                );
3455                println!("                   stryke (or $STRYKE_AOT_RUNTIME_LIB).");
3456                println!("  --project DIR    Bundle main.stk + lib/*.stk (excludes t/ tests)");
3457                println!("  --mcp-server     Wrap as an MCP server: after running the user's");
3458                println!("                   script (which calls `tool fn ...`), the binary");
3459                println!("                   serves the registered tools over stdio JSON-RPC.");
3460                println!();
3461                println!("Examples:");
3462                println!("  stryke build app.pl                     # → ./app");
3463                println!("  stryke build app.pl -o /usr/local/bin/app");
3464                println!("  stryke build --project ./myapp -o myapp # bundle project");
3465                println!("  stryke build tools.stk --mcp-server -o my-mcp-server");
3466                return 0;
3467            }
3468            s if script.is_none() && project_dir.is_none() && !s.starts_with('-') => {
3469                script = Some(s.to_string())
3470            }
3471            other => {
3472                eprintln!("stryke build: unknown argument: {}", other);
3473                eprintln!("usage: stryke build SCRIPT [-o OUTPUT]");
3474                eprintln!("       stryke build --project DIR [-o OUTPUT]");
3475                return 2;
3476            }
3477        }
3478        i += 1;
3479    }
3480
3481    // When `--mcp-server` is set, append a tail that flips the binary
3482    // into MCP-stdio mode after the user's script has registered its
3483    // tools. We do this by generating a wrapper temp file with the
3484    // user's script + a final `mcp_serve_registered_tools(...)` call.
3485    let script = if mcp_server {
3486        let Some(orig) = script.as_ref() else {
3487            eprintln!("stryke build --mcp-server: requires SCRIPT (project mode adds it to main.stk if you really need that)");
3488            return 2;
3489        };
3490        match wrap_for_mcp_server(orig, out.as_deref()) {
3491            Ok(path) => Some(path),
3492            Err(e) => {
3493                eprintln!("stryke build --mcp-server: {}", e);
3494                return 1;
3495            }
3496        }
3497    } else {
3498        script
3499    };
3500
3501    if native && project_dir.is_some() {
3502        eprintln!("stryke build --native: not supported with --project (single SCRIPT only)");
3503        return 2;
3504    }
3505
3506    if let Some(dir) = project_dir {
3507        let project_path = PathBuf::from(&dir);
3508        let out_path = PathBuf::from(out.unwrap_or_else(|| {
3509            project_path
3510                .file_name()
3511                .map(|s| s.to_string_lossy().into_owned())
3512                .unwrap_or_else(|| "a.out".to_string())
3513        }));
3514        match crate::aot::build_project(&project_path, &out_path) {
3515            Ok(p) => {
3516                eprintln!("stryke build: wrote {}", p.display());
3517                0
3518            }
3519            Err(e) => {
3520                eprintln!("{}", e);
3521                1
3522            }
3523        }
3524    } else {
3525        let Some(script) = script else {
3526            eprintln!("stryke build: missing SCRIPT or --project DIR");
3527            eprintln!("usage: stryke build SCRIPT [-o OUTPUT]");
3528            eprintln!("       stryke build --project DIR [-o OUTPUT]");
3529            return 2;
3530        };
3531        let script_path = PathBuf::from(&script);
3532        let out_path = PathBuf::from(out.unwrap_or_else(|| {
3533            script_path
3534                .file_stem()
3535                .map(|s| s.to_string_lossy().into_owned())
3536                .unwrap_or_else(|| "a.out".to_string())
3537        }));
3538        let result = if native {
3539            // Native AOT: lower to a fusevm chunk, compile to a Cranelift object,
3540            // link against libstryke.a. Covers the self-contained subset only.
3541            crate::aot_native::build_native(&script_path, &out_path)
3542        } else {
3543            crate::aot::build(&script_path, &out_path)
3544        };
3545        match result {
3546            Ok(p) => {
3547                eprintln!("stryke build: wrote {}", p.display());
3548                0
3549            }
3550            Err(e) => {
3551                eprintln!("{}", e);
3552                1
3553            }
3554        }
3555    }
3556}
3557
3558/// Generate a temporary `.stk` file that loads the user's script and then
3559/// flips into MCP-stdio mode. Used by `stryke build --mcp-server`. The
3560/// temp file lives next to the output so paths in error messages are
3561/// stable; if `out` is None, it goes in the system temp dir.
3562fn wrap_for_mcp_server(orig: &str, out: Option<&str>) -> Result<String, String> {
3563    use std::io::Write;
3564    let orig_abs =
3565        std::fs::canonicalize(orig).map_err(|e| format!("canonicalize {}: {}", orig, e))?;
3566    let dir = match out {
3567        Some(o) => PathBuf::from(o)
3568            .parent()
3569            .map(|p| p.to_path_buf())
3570            .unwrap_or_else(std::env::temp_dir),
3571        None => std::env::temp_dir(),
3572    };
3573    let pid = std::process::id();
3574    let nanos = std::time::SystemTime::now()
3575        .duration_since(std::time::UNIX_EPOCH)
3576        .map(|d| d.subsec_nanos())
3577        .unwrap_or(0);
3578    let wrapper = dir.join(format!("stryke-mcp-wrapper-{}-{}.stk", pid, nanos));
3579    let server_name = std::path::Path::new(orig)
3580        .file_stem()
3581        .map(|s| s.to_string_lossy().into_owned())
3582        .unwrap_or_else(|| "stryke-mcp".to_string());
3583    let body = format!(
3584        "# Auto-generated by `stryke build --mcp-server`. Loads the user's\n\
3585         # script (which registers tools via `tool fn ...`), then enters\n\
3586         # the MCP stdio JSON-RPC loop.\n\
3587         require {orig_abs:?};\n\
3588         mcp_serve_registered_tools({server_name:?});\n",
3589    );
3590    let mut f = std::fs::File::create(&wrapper)
3591        .map_err(|e| format!("create {}: {}", wrapper.display(), e))?;
3592    f.write_all(body.as_bytes())
3593        .map_err(|e| format!("write {}: {}", wrapper.display(), e))?;
3594    Ok(wrapper.to_string_lossy().into_owned())
3595}
3596
3597/// `stryke convert FILE...` — convert Perl source to idiomatic stryke syntax.
3598fn run_convert_subcommand(args: &[String]) -> i32 {
3599    let mut files: Vec<String> = Vec::new();
3600    let mut in_place = false;
3601    let mut output_delim: Option<char> = None;
3602    let mut i = 0;
3603    while i < args.len() {
3604        match args[i].as_str() {
3605            "-i" | "--in-place" => in_place = true,
3606            "-d" | "--output-delim" => {
3607                i += 1;
3608                if i >= args.len() {
3609                    eprintln!("stryke convert: --output-delim requires an argument");
3610                    return 2;
3611                }
3612                let delim_str = &args[i];
3613                if delim_str.chars().count() != 1 {
3614                    eprintln!(
3615                        "stryke convert: --output-delim must be a single character, got {:?}",
3616                        delim_str
3617                    );
3618                    return 2;
3619                }
3620                output_delim = delim_str.chars().next();
3621            }
3622            "-h" | "--help" => {
3623                println!("usage: stryke convert [-i] [-d DELIM] FILE...");
3624                println!();
3625                println!("Convert standard Perl source to idiomatic stryke syntax:");
3626                println!("  - Nested calls → |> pipe-forward chains");
3627                println!("  - map/grep/sort/join LIST → LIST |> map/grep/sort/join");
3628                println!("  - No trailing semicolons");
3629                println!("  - 4-space indentation");
3630                println!("  - #!/usr/bin/env stryke shebang");
3631                println!();
3632                println!("Options:");
3633                println!("  -i, --in-place       Write .stk files alongside originals");
3634                println!("  -d, --output-delim   Delimiter for s///, tr///, m// (default: preserve original)");
3635                println!();
3636                println!("Examples:");
3637                println!("  stryke convert app.pl              # print to stdout");
3638                println!("  stryke convert -i lib/*.pm         # write lib/*.stk");
3639                println!("  stryke convert -d '|' app.pl       # use | as delimiter: s|old|new|g");
3640                return 0;
3641            }
3642            s if s.starts_with('-') => {
3643                eprintln!("stryke convert: unknown option: {}", s);
3644                eprintln!("usage: stryke convert [-i] [-d DELIM] FILE...");
3645                return 2;
3646            }
3647            s => files.push(s.to_string()),
3648        }
3649        i += 1;
3650    }
3651    if files.is_empty() {
3652        eprintln!("stryke convert: no input files");
3653        eprintln!("usage: stryke convert [-i] [-d DELIM] FILE...");
3654        return 2;
3655    }
3656    let opts = crate::convert::ConvertOptions {
3657        output_delim,
3658        ..Default::default()
3659    };
3660    let mut errors = 0;
3661    for f in &files {
3662        let code = match std::fs::read_to_string(f) {
3663            Ok(c) => c,
3664            Err(e) => {
3665                eprintln!("stryke convert: {}: {}", f, e);
3666                errors += 1;
3667                continue;
3668            }
3669        };
3670        let program = match crate::parse_with_file(&code, f) {
3671            Ok(p) => p,
3672            Err(e) => {
3673                eprintln!("stryke convert: {}: {}", f, e);
3674                errors += 1;
3675                continue;
3676            }
3677        };
3678        let converted = crate::convert_to_stryke_with_options(&program, &opts);
3679        if in_place {
3680            let out_path = std::path::Path::new(f).with_extension("pr");
3681            if let Err(e) = std::fs::write(&out_path, &converted) {
3682                eprintln!("stryke convert: {}: {}", out_path.display(), e);
3683                errors += 1;
3684            }
3685        } else {
3686            println!("{}", converted);
3687        }
3688    }
3689    if errors > 0 {
3690        1
3691    } else {
3692        0
3693    }
3694}
3695
3696/// `stryke serve [PORT] [SCRIPT]` or `stryke serve [PORT] -e CODE` — start an HTTP server (default port 8000).
3697///
3698/// Wraps the user's handler in `serve(PORT, fn ($req) { ... })`.
3699fn run_serve_subcommand(args: &[String]) -> i32 {
3700    if !args.is_empty() && (args[0] == "-h" || args[0] == "--help") {
3701        eprintln!("usage: stryke serve [PORT] [SCRIPT | -e CODE]");
3702        eprintln!();
3703        eprintln!("  stryke serve                   serve $PWD on port 8000");
3704        eprintln!("  stryke serve PORT              serve $PWD as static files");
3705        eprintln!("  stryke serve PORT SCRIPT       run script (must call serve())");
3706        eprintln!("  stryke serve PORT -e CODE      one-liner handler");
3707        eprintln!();
3708        eprintln!("  Handler receives $req (hashref: method, path, query, headers, body, peer)");
3709        eprintln!("  and returns: string (200 OK), key-value pairs, hashref, or undef (404).");
3710        eprintln!();
3711        eprintln!("examples:");
3712        eprintln!(
3713            "  stryke serve                                              # static file server on 8000"
3714        );
3715        eprintln!(
3716            "  stryke serve 8080                                         # static file server"
3717        );
3718        eprintln!("  stryke serve 8080 app.stk                                 # script handler");
3719        eprintln!("  stryke serve 3000 -e '\"hello \" . $req->{{path}}'           # one-liner");
3720        eprintln!("  stryke serve 8080 -e 'status => 200, body => json_encode(+{{ok => 1}})'");
3721        return 0;
3722    }
3723
3724    // If first arg is a valid port number, consume it; otherwise default to 8000.
3725    let (port, rest) = if !args.is_empty() && args[0].parse::<u16>().is_ok() {
3726        (args[0].clone(), &args[1..])
3727    } else {
3728        ("8000".to_string(), args)
3729    };
3730
3731    // Detect mode: no arg or directory = static file server, -e = one-liner, else = script
3732    let static_dir = if rest.is_empty() {
3733        Some(
3734            std::env::current_dir()
3735                .unwrap_or_default()
3736                .to_string_lossy()
3737                .to_string(),
3738        )
3739    } else if rest[0] != "-e" && Path::new(&rest[0]).is_dir() {
3740        Some(
3741            std::fs::canonicalize(&rest[0])
3742                .unwrap_or_else(|_| PathBuf::from(&rest[0]))
3743                .to_string_lossy()
3744                .to_string(),
3745        )
3746    } else {
3747        None
3748    };
3749
3750    let code = if let Some(dir) = static_dir {
3751        let dir_escaped = dir.replace('\\', "\\\\").replace('"', "\\\"");
3752        eprintln!("stryke: serving {} on http://0.0.0.0:{}", dir, port);
3753        format!(
3754            r#"
3755chdir "{dir_escaped}"
3756
3757my %mime = (
3758    html => "text/html; charset=utf-8",
3759    htm => "text/html; charset=utf-8",
3760    css => "text/css; charset=utf-8",
3761    js => "application/javascript; charset=utf-8",
3762    mjs => "application/javascript; charset=utf-8",
3763    json => "application/json; charset=utf-8",
3764    xml => "text/xml; charset=utf-8",
3765    md => "text/markdown; charset=utf-8",
3766    txt => "text/plain; charset=utf-8",
3767    toml => "application/toml; charset=utf-8",
3768    pl => "text/x-perl; charset=utf-8",
3769    pr => "text/x-perl; charset=utf-8",
3770    pm => "text/x-perl; charset=utf-8",
3771    png => "image/png",
3772    jpg => "image/jpeg",
3773    jpeg => "image/jpeg",
3774    gif => "image/gif",
3775    svg => "image/svg+xml",
3776    webp => "image/webp",
3777    avif => "image/avif",
3778    ico => "image/x-icon",
3779    woff2 => "font/woff2",
3780    woff => "font/woff",
3781    ttf => "font/ttf",
3782    mp3 => "audio/mpeg",
3783    ogg => "audio/ogg",
3784    mp4 => "video/mp4",
3785    webm => "video/webm",
3786    zip => "application/zip",
3787    gz => "application/gzip",
3788    wasm => "application/wasm",
3789    pdf => "application/pdf"
3790)
3791
3792fn mime_for($path) {{
3793    my $ext = $path =~ /\.([^.]+)$/ ? lc($1) : ""
3794    $mime{{$ext}} // "text/plain"
3795}}
3796
3797fn dir_listing($url_path, $fs_path) {{
3798    $url_path .= "/" unless $url_path =~ m|/$|
3799    my $prefix = $fs_path eq "." ? "" : "$fs_path/"
3800    my @entries
3801    push @entries, ".." unless $url_path eq "/"
3802    push @entries, dirs($fs_path)
3803    push @entries, filesf($fs_path)
3804    my $html = ""
3805    for my $e (@entries) {{
3806        my $full = $e eq ".." ? ".." : "$prefix$e"
3807        my $name = $e
3808        my $href = $url_path . $name
3809        if (-d $full) {{
3810            $html .= "<li class=\"dir\"><a href=\"$href/\">$name/</a></li>"
3811        }} else {{
3812            my $sz = (stat($full))[7] // 0
3813            $html .= "<li><a href=\"$href\">$name</a> <span style=\"color:#888\">($sz bytes)</span></li>"
3814        }}
3815    }}
3816    "<!DOCTYPE html><html><head><meta charset=\"utf-8\">"
3817    . "<title>Directory listing for $url_path</title>"
3818    . "<style>body{{font-family:monospace;margin:2em}}a{{text-decoration:none}}a:hover{{text-decoration:underline}}li{{padding:2px 0}}.dir{{font-weight:bold}}</style>"
3819    . "</head><body><h1>Directory listing for $url_path</h1><hr><ul>"
3820    . $html
3821    . "</ul><hr><p style=\"color:#888\">stryke/{port}</p></body></html>"
3822}}
3823
3824serve {port}, fn ($req) {{
3825    my $url_path = $req->{{path}}
3826    $url_path =~ s|\.\./||g
3827    my $fs_path = $url_path =~ s|^/||r
3828    $fs_path = "." if $fs_path eq ""
3829
3830    if (-d $fs_path) {{
3831        my $idx = $fs_path eq "." ? "index.html" : "$fs_path/index.html"
3832        if (-f $idx) {{
3833            +{{ status => 200, body => cat($idx), headers => +{{ "content-type" => "text/html; charset=utf-8" }} }}
3834        }} else {{
3835            +{{ status => 200, body => dir_listing($url_path, $fs_path), headers => +{{ "content-type" => "text/html; charset=utf-8" }} }}
3836        }}
3837    }} elsif (-f $fs_path) {{
3838        +{{ status => 200, body => cat($fs_path), headers => +{{ "content-type" => mime_for($fs_path) }} }}
3839    }} else {{
3840        +{{ status => 404, body => "404 Not Found: $url_path\n" }}
3841    }}
3842}}
3843"#
3844        )
3845    } else if rest[0] == "-e" {
3846        if rest.len() < 2 {
3847            eprintln!("stryke serve: -e requires an argument");
3848            return 1;
3849        }
3850        let handler_body = rest[1..].join(" ");
3851        format!("serve {}, fn ($req) {{ {} }}", port, handler_body)
3852    } else {
3853        // Script file — the script must call serve() itself.
3854        // PORT is injected as $ENV{STRYKE_PORT} for convenience.
3855        let script_path = &rest[0];
3856        match std::fs::read_to_string(script_path) {
3857            Ok(src) => {
3858                format!("$ENV{{STRYKE_PORT}} = {}\n{}", port, src)
3859            }
3860            Err(e) => {
3861                eprintln!("stryke serve: {}: {}", script_path, e);
3862                return 1;
3863            }
3864        }
3865    };
3866
3867    let mut interp = crate::vm_helper::VMHelper::new();
3868    match crate::parse_and_run_string(&code, &mut interp) {
3869        Ok(_) => 0,
3870        Err(e) => {
3871            if let crate::error::ErrorKind::Exit(code) = e.kind {
3872                return code;
3873            }
3874            eprintln!("{}", e);
3875            255
3876        }
3877    }
3878}
3879
3880#[allow(non_snake_case)]
3881/// `stryke docs [TOPIC]` — interactive built-in documentation book.
3882///
3883/// - `stryke docs`          → full-screen interactive book (vim-style navigation)
3884/// - `stryke docs TOPIC`    → single-topic lookup
3885/// - `stryke docs -t`       → table of contents
3886/// - `stryke docs -s PAT`   → search topics
3887/// - `stryke docs -h`       → help
3888fn run_doc_subcommand(args: &[String]) -> i32 {
3889    let theme = DocTheme {
3890        C: "\x1b[36m",
3891        G: "\x1b[32m",
3892        Y: "\x1b[1;33m",
3893        M: "\x1b[35m",
3894        B: "\x1b[1m",
3895        D: "\x1b[2m",
3896        N: "\x1b[0m",
3897    };
3898    let DocTheme {
3899        C,
3900        G,
3901        Y,
3902        M,
3903        B,
3904        D,
3905        N,
3906    } = theme;
3907
3908    // Build doc pages in `DOC_CATEGORIES` order so the book reads in the
3909    // same sequence as `docs/reference.html` (which `gen_docs.rs` walks
3910    // identically). The previous build walked `category_map_iter()` —
3911    // alphabetical-by-name in `CATEGORY_MAP` — which interleaved
3912    // chapters across the entire book, broke `[` / `]` chapter
3913    // navigation (chapter changed on nearly every page boundary), and
3914    // tagged each page with the `CATEGORY_MAP` source-comment label
3915    // ("Base conversion", "Bit ops", …) that didn't match the
3916    // intro/TOC chapter names from `DOC_CATEGORIES`.
3917    //
3918    // Three passes:
3919    //   1. `DOC_CATEGORIES` — curated chapter order, topic order
3920    //      preserved; the user-facing book sequence.
3921    //   2. Every `CATEGORY_MAP` primary not yet placed → "Other", so
3922    //      `s docs --list` count still equals `len(keys %b)`.
3923    //   3. Hand-written hover entries (keywords, operators, sigil
3924    //      reflection hashes) not yet placed → "Other".
3925    //
3926    // Dedup is by name only — auto-stubbed pages share `&'static str`
3927    // pointers across aliases (`"sum" | "sum0" => "..."` returns the
3928    // same str) but each primary still gets its own rendered page
3929    // because `render_page_content` writes the topic name into the
3930    // heading.
3931    let mut entries: Vec<(&str, &str, String)> = Vec::new();
3932    let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
3933    for &(chapter, topics) in crate::lsp::DOC_CATEGORIES {
3934        for &t in topics {
3935            if seen.contains(t) {
3936                continue;
3937            }
3938            if let Some(text) = crate::lsp::doc_text_for(t) {
3939                let rendered = render_page_content(t, text, C, G, D, N);
3940                entries.push((chapter, t, rendered));
3941                seen.insert(t);
3942            }
3943        }
3944    }
3945    // Group remaining primaries by their CATEGORY_MAP source-comment
3946    // category so each chapter is a contiguous block — without this
3947    // every leftover (~7800 entries) collapsed into a single giant
3948    // "Other" chapter while DOC_CATEGORIES carried only ~855 hand-
3949    // listed entries. CATEGORY_MAP is alphabetical-by-name, so the
3950    // categories interleave; sort by (category, name) here to make
3951    // every category a contiguous run.
3952    //
3953    // Empty / missing category strings keep the "Other" fallback so
3954    // truly uncategorized primaries still have a home.
3955    let mut leftover: Vec<(&'static str, &'static str)> = crate::builtins::category_map_iter()
3956        .filter(|(name, _)| !seen.contains(name))
3957        .map(|(name, cat)| {
3958            let chapter = if cat.is_empty() { "Other" } else { cat };
3959            (chapter, name)
3960        })
3961        .collect();
3962    leftover.sort_by(|a, b| a.0.cmp(b.0).then(a.1.cmp(b.1)));
3963    for (chapter, name) in leftover {
3964        if seen.contains(name) {
3965            continue;
3966        }
3967        if let Some(text) = crate::lsp::doc_text_for(name) {
3968            let rendered = render_page_content(name, text, C, G, D, N);
3969            entries.push((chapter, name, rendered));
3970            seen.insert(name);
3971        }
3972    }
3973    // Hand-written hover entries that aren't dispatch primaries — and
3974    // ALSO aren't aliases (every alias resolves to a primary's page).
3975    // Keywords, operators, sigil-prefixed reflection hashes (`~>`,
3976    // `match`, `%a`, …) are the only third-pass additions.
3977    for topic in crate::lsp::doc_topics() {
3978        if seen.contains(topic) {
3979            continue;
3980        }
3981        // Skip every callable spelling — primaries are already in
3982        // earlier passes, aliases share their primary's page.
3983        if crate::builtins::is_callable_spelling(topic) {
3984            continue;
3985        }
3986        // `CORE::name` / `main::name` qualified spellings are tab-
3987        // complete sugar (every callable is reachable via either
3988        // qualifier) and should NOT show up as separate browsing
3989        // entries — `s docs --list` would otherwise be flooded with
3990        // duplicates of every primary. They keep working as queries
3991        // because the topic resolver strips the prefix; only the
3992        // *list* stays clean. The bare `CORE` and `main` namespace
3993        // pages still appear via the KEYWORDS list in
3994        // `builtin_lsp_completion_words`.
3995        if topic.starts_with("CORE::") || topic.starts_with("main::") {
3996            continue;
3997        }
3998        if let Some(text) = crate::lsp::doc_text_for(topic) {
3999            let rendered = render_page_content(topic, text, C, G, D, N);
4000            entries.push(("Other", topic, rendered));
4001            seen.insert(topic);
4002        }
4003    }
4004    if entries.is_empty() {
4005        eprintln!("stryke docs: no documentation pages found");
4006        return 1;
4007    }
4008
4009    // Pack topics until adding the next would overflow the content area.
4010    // Header=11 rows, footer=3 rows → content area = term_h - 14.
4011    let content_area = term_height().saturating_sub(14).max(4);
4012    let mut pages = build_fixed_pages(&entries, content_area);
4013
4014    // Insert intro page at position 0. Chapter list / count are
4015    // derived from the actual placed entries (preserves DOC_CATEGORIES
4016    // order, drops chapters whose topics all lack hover text, and
4017    // includes the trailing "Other" leftover chapter when present).
4018    let entry_count = entries.len();
4019    let mut chapter_counts: Vec<(&str, usize)> = Vec::new();
4020    for (cat, _, _) in &entries {
4021        if let Some(slot) = chapter_counts.iter_mut().find(|(c, _)| *c == *cat) {
4022            slot.1 += 1;
4023        } else {
4024            chapter_counts.push((*cat, 1));
4025        }
4026    }
4027    let chapter_count = chapter_counts.len();
4028    let mut intro = format!(
4029        "\
4030  {D}>> THE STRYKE ENCYCLOPEDIA // INTERACTIVE REFERENCE SYSTEM <<{N}\n\
4031\n\
4032  {B}A comprehensive reference for every stryke builtin, keyword,{N}\n\
4033  {B}and extension. {G}{entry_count}{N} {B}topics across {G}{chapter_count}{N} {B}chapters.{N}\n\
4034\n\
4035  {D}── GETTING STARTED ─────────────────────────────────────────────{N}\n\
4036\n\
4037  {C}j{N} / {C}n{N} / {C}space{N}        next page\n\
4038  {C}k{N} / {C}p{N}                previous page\n\
4039  {C}]{N} / {C}[{N}                next / previous chapter\n\
4040  {C}d{N} / {C}u{N}                forward / back 5 pages\n\
4041  {C}g{N} / {C}G{N}                first / last page\n\
4042  {C}t{N}                    table of contents\n\
4043  {C}/{N}                    search all pages\n\
4044  {C}:{N}                    jump to page number\n\
4045  {C}r{N}                    random page\n\
4046  {C}?{N}                    full keybinding help\n\
4047  {C}q{N}                    quit\n\
4048\n\
4049  {D}── CHAPTERS ───────────────────────────────────────────────────{N}\n\
4050"
4051    );
4052    // Only the curated DOC_CATEGORIES chapters get listed on the intro
4053    // page — leftover CATEGORY_MAP source-comment chapters (~320 small
4054    // topical buckets) and the trailing "Other" hover-entry chapter
4055    // would overflow any terminal. Their counts are summarized as one
4056    // tail line; full breakdown lives in the TOC (`t`).
4057    let major_chapters: std::collections::HashSet<&str> =
4058        crate::lsp::DOC_CATEGORIES.iter().map(|(c, _)| *c).collect();
4059    let mut major_topics = 0usize;
4060    let mut minor_topics = 0usize;
4061    let mut minor_chapter_count = 0usize;
4062    let mut display_idx = 0usize;
4063    for (cat, count) in chapter_counts.iter() {
4064        if major_chapters.contains(cat) {
4065            display_idx += 1;
4066            major_topics += count;
4067            intro.push_str(&format!(
4068                "  {C}{:>2}.{N} {B}{:<40}{N} {D}{} topics{N}\n",
4069                display_idx, cat, count,
4070            ));
4071        } else {
4072            minor_chapter_count += 1;
4073            minor_topics += count;
4074        }
4075    }
4076    if minor_chapter_count > 0 {
4077        intro.push_str(&format!(
4078            "  {D}…{N}  {B}{:<40}{N} {D}{} topics{N}\n",
4079            format!("+ {} more chapters", minor_chapter_count),
4080            minor_topics,
4081        ));
4082    }
4083    let _ = major_topics;
4084    intro.push_str(&format!(
4085        "\n  {D}press {C}t{D} for full table of contents, {C}j{D} or {C}space{D} to begin >>>{N}\n"
4086    ));
4087    // Pad intro to content area height
4088    let intro_page = pad_to_height(&intro, content_area);
4089    pages.insert(0, ("Introduction".to_string(), intro_page, Vec::new()));
4090    let total = pages.len();
4091
4092    if args.first().map(|s| s.as_str()) == Some("-h")
4093        || args.first().map(|s| s.as_str()) == Some("--help")
4094    {
4095        println!();
4096        doc_print_banner(theme);
4097        doc_print_hline('┌', '┐', theme);
4098        doc_print_boxline(
4099            &format!(" {G}STATUS: ONLINE{N}  {D}//{N} {C}SIGNAL: {G}████████{D}░░{N}  {D}//{N} {M}STRYKE DOCS{N}"),
4100            theme,
4101        );
4102        doc_print_hline('└', '┘', theme);
4103        println!("  {D}>> THE STRYKE ENCYCLOPEDIA // INTERACTIVE REFERENCE SYSTEM <<{N}");
4104        println!();
4105        println!("  {B}USAGE:{N} stryke docs {D}[OPTIONS] [PAGE|TOPIC]{N}");
4106        println!();
4107        doc_print_separator("OPTIONS", theme);
4108        println!("  {C}-h, --help{N}                          {D}// Show this help{N}");
4109        println!("  {C}-t, --toc{N}                           {D}// Table of contents{N}");
4110        println!("  {C}-s, --search <pattern>{N}              {D}// Search pages{N}");
4111        println!("  {C}-l, --list{N}                          {D}// List all pages (one per primary + keywords){N}");
4112        println!("  {C}-L, --list-all{N}                      {D}// Every callable spelling (primaries + aliases + CORE::*, main::*) — for shell tab-complete{N}");
4113        println!(
4114            "  {C}TOPIC{N}                               {D}// Jump to topic (stryke docs pmap){N}"
4115        );
4116        println!("  {C}PAGE{N}                                {D}// Jump to page number{N}");
4117        println!();
4118        doc_print_separator("NAVIGATION (vim-style)", theme);
4119        println!("  {C}j / n / l / enter / space{N}           {D}// Next page{N}");
4120        println!("  {C}k / p / h{N}                           {D}// Previous page{N}");
4121        println!("  {C}d{N}                                   {D}// Forward 5 pages{N}");
4122        println!("  {C}u{N}                                   {D}// Back 5 pages{N}");
4123        println!("  {C}g / 0{N}                               {D}// First page{N}");
4124        println!("  {C}G / ${N}                               {D}// Last page{N}");
4125        println!("  {C}] / }}{N}                              {D}// Next chapter{N}");
4126        println!("  {C}[ / {{{N}                              {D}// Previous chapter{N}");
4127        println!("  {C}t{N}                                   {D}// Table of contents{N}");
4128        println!("  {C}/ <pattern>{N}                         {D}// Search pages{N}");
4129        println!("  {C}:<number>{N}                           {D}// Jump to page{N}");
4130        println!("  {C}r{N}                                   {D}// Random page{N}");
4131        println!("  {C}?{N}                                   {D}// Keybinding help{N}");
4132        println!("  {C}q{N}                                   {D}// Quit{N}");
4133        println!();
4134        doc_print_separator("EXAMPLES", theme);
4135        println!("  {C}stryke docs{N}                             {D}// start from page 1{N}");
4136        println!("  {C}stryke docs --toc{N}                       {D}// table of contents{N}");
4137        println!("  {C}stryke docs 42{N}                          {D}// jump to page 42{N}");
4138        println!("  {C}stryke docs pmap{N}                        {D}// jump to pmap{N}");
4139        println!("  {C}stryke docs --search parallel{N}           {D}// find parallel pages{N}");
4140        println!();
4141        return 0;
4142    }
4143
4144    // --toc: print table of contents and exit
4145    if args.first().map(|s| s.as_str()) == Some("-t")
4146        || args.first().map(|s| s.as_str()) == Some("--toc")
4147    {
4148        doc_print_toc_entries(&entries, &pages, theme);
4149        return 0;
4150    }
4151
4152    // --list: compact list of distinct topic *pages* (one entry per
4153    // primary + a small "Other" tail of keywords / namespaces). What
4154    // a human wants when browsing — no alias / qualifier noise.
4155    if args.first().map(|s| s.as_str()) == Some("-l")
4156        || args.first().map(|s| s.as_str()) == Some("--list")
4157    {
4158        for (i, (_, topic, _)) in entries.iter().enumerate() {
4159            println!("{:>3}. {}", i + 1, topic);
4160        }
4161        return 0;
4162    }
4163
4164    // --list-all: every callable *spelling* the user might type after
4165    // `s docs` — primaries + aliases + `CORE::name` / `main::name`
4166    // qualified spellings + sigil-prefixed reflection hashes +
4167    // language keywords. Drives zsh / bash tab-complete:
4168    //
4169    //     _stryke_docs() {
4170    //         local -a t=(${(f)"$(s docs --list-all 2>/dev/null \
4171    //             | sed 's/^ *[0-9]*\. //')"})
4172    //         _describe 'docs topic' t
4173    //     }
4174    //
4175    // Format matches `--list` (` N. name`) so existing completion
4176    // shims keep working.
4177    if args.first().map(|s| s.as_str()) == Some("--list-all")
4178        || args.first().map(|s| s.as_str()) == Some("-L")
4179    {
4180        let mut all: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
4181        // Every page topic from `--list` (primaries + keywords/Other).
4182        for (_, topic, _) in &entries {
4183            all.insert(topic.to_string());
4184        }
4185        // Every aliased callable (so `tj`, `bn`, etc. show up).
4186        for (name, _) in crate::builtins::category_map_iter() {
4187            all.insert(name.to_string());
4188        }
4189        for entry in crate::builtins::aliases_hash_map().keys() {
4190            all.insert(entry.clone());
4191        }
4192        // CORE:: + main:: qualified spellings of every callable.
4193        let bare_callables: Vec<String> = crate::builtins::category_map_iter()
4194            .map(|(n, _)| n.to_string())
4195            .chain(
4196                crate::builtins::aliases_hash_map()
4197                    .keys()
4198                    .cloned()
4199                    .collect::<Vec<_>>(),
4200            )
4201            .collect();
4202        for n in &bare_callables {
4203            all.insert(format!("CORE::{n}"));
4204            all.insert(format!("main::{n}"));
4205        }
4206        // Sigil-prefixed reflection hashes / standard globals — every
4207        // entry from `lsp_completion_words.txt` that starts with a
4208        // sigil. Cheap: it's an `include_str!`'d static slice.
4209        for line in include_str!("lsp_completion_words.txt").lines() {
4210            let line = line.trim();
4211            if line.is_empty() || line.starts_with('#') {
4212                continue;
4213            }
4214            // Sigil-prefixed entries and qualified entries (CORE::,
4215            // main::, crate::) flow through here unconditionally —
4216            // they're all valid topic queries.
4217            all.insert(line.to_string());
4218        }
4219        for (i, name) in all.iter().enumerate() {
4220            println!("{:>5}. {}", i + 1, name);
4221        }
4222        return 0;
4223    }
4224
4225    // --search: search and exit
4226    if (args.first().map(|s| s.as_str()) == Some("-s")
4227        || args.first().map(|s| s.as_str()) == Some("--search"))
4228        && args.len() >= 2
4229    {
4230        let pat = args[1].to_lowercase();
4231        let mut found = 0;
4232        for (i, (cat, topic, text)) in entries.iter().enumerate() {
4233            if topic.to_lowercase().contains(&pat)
4234                || cat.to_lowercase().contains(&pat)
4235                || text.to_lowercase().contains(&pat)
4236            {
4237                println!("  {C}{:>3}.{N} {B}{}{N}  {D}({}){N}", i + 1, topic, cat);
4238                found += 1;
4239            }
4240        }
4241        if found == 0 {
4242            println!("  {Y}no results for '{}'{N}", pat);
4243        }
4244        return 0;
4245    }
4246
4247    // Single topic or page number — find which page contains it.
4248    // `topic_entry_idx` stays Some(idx) when the user named a specific
4249    // builtin / keyword (so we render JUST that entry, `man pmap` style)
4250    // and stays None when they passed a page number or no arg (in which
4251    // case we render the full page for browsing).
4252    let mut start_page: usize = 0;
4253    let mut topic_entry_idx: Option<usize> = None;
4254    if !args.is_empty() {
4255        let arg = &args[0];
4256        // Try page number
4257        if let Ok(n) = arg.parse::<usize>() {
4258            if n >= 1 && n <= total {
4259                start_page = n - 1;
4260            }
4261        } else {
4262            // Try topic name → find which page contains that entry.
4263            //
4264            // Pseudo-namespace prefixes (`CORE::name`, `main::name`)
4265            // are dispatch aliases — every callable bare name is also
4266            // reachable as `CORE::name`, and every top-level binding
4267            // resolves through `main::name`. Strip the prefix and look
4268            // up the bare name's page so `s docs CORE::print` ≡
4269            // `s docs print` and `s docs main::pmap` ≡ `s docs pmap`.
4270            //
4271            // The bare prefix itself (`CORE`, `main`, `stryke`) keeps
4272            // its own dedicated namespace topic page via the hand-
4273            // written hover entries in `lsp.rs`, so `s docs CORE`
4274            // explains the namespace and `s docs CORE::print`
4275            // navigates to `print`.
4276            let resolved: String = if let Some(rest) = arg.strip_prefix("CORE::") {
4277                rest.to_string()
4278            } else if let Some(rest) = arg.strip_prefix("main::") {
4279                rest.to_string()
4280            } else if let Some(rest) = arg.strip_prefix("crate::") {
4281                // `crate::builtins`, `crate::all`, `crate::aliases`,
4282                // … all have hand-written hover entries under the
4283                // sigil-prefixed `%crate::NAME` spelling — that's the
4284                // primary key. Try the sigil form first; only fall
4285                // through to the bare name if the sigil form has no
4286                // entry. Without the sigil-priority, `s docs
4287                // crate::all` mis-resolved to the `all` builtin (a
4288                // separate primary that happens to share the suffix).
4289                let sigil = format!("%crate::{}", rest);
4290                let sigil_low = sigil.to_lowercase();
4291                if entries
4292                    .iter()
4293                    .any(|(_, t, _)| t.to_lowercase() == sigil_low)
4294                {
4295                    sigil
4296                } else {
4297                    rest.to_string()
4298                }
4299            } else {
4300                arg.clone()
4301            };
4302            let lower = resolved.to_lowercase();
4303            let entry_idx = entries
4304                .iter()
4305                .position(|(_, t, _)| t.to_lowercase() == lower)
4306                .or_else(|| {
4307                    // Alias resolution: after `CORE::tj` strips to
4308                    // `tj`, the bare alias isn't in `entries` (filtered
4309                    // out as a callable spelling — its primary's page
4310                    // is what users want). Look the alias up in
4311                    // `%crate::aliases` and try the primary.
4312                    if let Some(primary) = crate::builtins::primary_for_alias(&resolved) {
4313                        let lp = primary.to_lowercase();
4314                        if let Some(i) = entries.iter().position(|(_, t, _)| t.to_lowercase() == lp)
4315                        {
4316                            return Some(i);
4317                        }
4318                    }
4319                    None
4320                })
4321                .or_else(|| {
4322                    // Sigil-prefixed reflection-hash entries: `s docs
4323                    // crate::builtins` should resolve to the
4324                    // `%crate::builtins` page.
4325                    let sigil_form = format!("%{}", resolved);
4326                    let lp = sigil_form.to_lowercase();
4327                    entries.iter().position(|(_, t, _)| t.to_lowercase() == lp)
4328                })
4329                .or_else(|| {
4330                    // Substring fallback only when the query is
4331                    // distinctive (≥ 3 chars) AND the topic STARTS
4332                    // WITH the query. `contains` was too permissive —
4333                    // `s docs CORE` matched `blosum45_score` because
4334                    // "score" contains "core".
4335                    if lower.len() < 3 {
4336                        return None;
4337                    }
4338                    entries
4339                        .iter()
4340                        .position(|(_, t, _)| t.to_lowercase().starts_with(&lower))
4341                });
4342            match entry_idx {
4343                Some(eidx) => {
4344                    // Find the page that contains this entry index
4345                    start_page = pages
4346                        .iter()
4347                        .position(|(_, _, indices)| indices.contains(&eidx))
4348                        .unwrap_or(0);
4349                    topic_entry_idx = Some(eidx);
4350                }
4351                None => {
4352                    // Not a registered browsing page, but it may still
4353                    // have hover/doc text (e.g. the `style-guide` /
4354                    // `styleguide` aliases of the `style` topic). Render
4355                    // it directly, `man`-style, before giving up.
4356                    if let Some(text) = crate::lsp::doc_text_for(&resolved) {
4357                        let rendered = render_page_content(&resolved, text, C, G, D, N);
4358                        print!("{}", rendered);
4359                        if !rendered.ends_with('\n') {
4360                            println!();
4361                        }
4362                        return 0;
4363                    }
4364                    eprintln!("stryke docs: no documentation for '{}'", arg);
4365                    eprintln!("run 'stryke docs -h' for help");
4366                    return 1;
4367                }
4368            }
4369        }
4370    }
4371
4372    // ── Interactive TUI book mode ────────────────────────────
4373    // The TUI is only useful for *browsing* without a target — when the
4374    // caller named a specific topic or page number, dump that page and
4375    // exit (`man pmap` semantics). Otherwise gate on real TTYs for
4376    // both stdin and stdout, since headless wrappers (CI, AI agents
4377    // like Gemini's exec, `s docs foo | cat`) often keep stdout as a
4378    // tty while piping stdin and the read loop would block forever.
4379    // `STRYKE_NO_TTY=1` / `NO_TTY=1` force non-interactive even on a
4380    // real terminal.
4381    let target_specified = !args.is_empty();
4382    let no_tty_env =
4383        std::env::var_os("STRYKE_NO_TTY").is_some() || std::env::var_os("NO_TTY").is_some();
4384    let interactive_ok =
4385        !target_specified && io::stdout().is_terminal() && io::stdin().is_terminal() && !no_tty_env;
4386    if !interactive_ok {
4387        // `man pmap` mode: when the user named a specific topic (via a
4388        // bareword arg, NOT a page number), render only that entry's
4389        // content. Otherwise (no arg, or numeric page number), render
4390        // the full packed page so browsers see the planned layout.
4391        if let Some(eidx) = topic_entry_idx {
4392            print!("{}", entries[eidx].2);
4393            // entries[*].2 doesn't always end with \n — make sure
4394            // shell prompts land on their own line.
4395            if !entries[eidx].2.ends_with('\n') {
4396                println!();
4397            }
4398        } else {
4399            print!("{}", pages[start_page].1);
4400        }
4401        return 0;
4402    }
4403
4404    doc_interactive_loop(&pages, &entries, &intro, start_page, total, theme)
4405}
4406
4407/// Truncate/pad text to exactly `height` lines, joined with `\r\n`.
4408fn pad_to_height(text: &str, height: usize) -> String {
4409    let lines: Vec<&str> = text.lines().collect();
4410    let mut buf: Vec<&str> = Vec::with_capacity(height);
4411    for line in lines.iter().take(height) {
4412        buf.push(line);
4413    }
4414    while buf.len() < height {
4415        buf.push("");
4416    }
4417    buf.join("\r\n")
4418}
4419
4420/// Pack topics into pages that fit within `max_lines` of content.
4421/// Pack 2–3 entries per page. Uses 3 when they fit in `max_lines`,
4422/// otherwise 2. New chapter always starts a new page.
4423fn build_fixed_pages(
4424    entries: &[(&str, &str, String)],
4425    max_lines: usize,
4426) -> Vec<(String, String, Vec<usize>)> {
4427    let mut pages: Vec<(String, String, Vec<usize>)> = Vec::new();
4428    let mut i = 0;
4429    while i < entries.len() {
4430        let cat = entries[i].0.to_string();
4431        // Always take at least 2 (or 1 if last entry)
4432        let mut end = (i + 2).min(entries.len());
4433        // Try to fit a 3rd if same chapter and lines fit
4434        if end < entries.len() && entries[end].0 == cat {
4435            let lines: usize = (i..=end).map(|j| entries[j].2.lines().count() + 1).sum();
4436            if lines <= max_lines {
4437                end += 1;
4438            }
4439        }
4440        // Stop at chapter boundary
4441        if let Some(pos) = entries[i + 1..end].iter().position(|e| e.0 != cat) {
4442            end = i + 1 + pos;
4443        }
4444        let mut buf = String::new();
4445        let mut indices = Vec::new();
4446        for (j, entry) in entries.iter().enumerate().take(end).skip(i) {
4447            if j > i {
4448                buf.push('\n');
4449            }
4450            buf.push_str(&entry.2);
4451            indices.push(j);
4452        }
4453        pages.push((cat, buf, indices));
4454        i = end;
4455    }
4456    pages
4457}
4458
4459/// Find the page whose `indices` contains `entry_idx`.
4460fn find_page_for_entry(pages: &[(String, String, Vec<usize>)], entry_idx: usize) -> usize {
4461    for (pi, (_cat, _content, indices)) in pages.iter().enumerate() {
4462        if indices.contains(&entry_idx) {
4463            return pi;
4464        }
4465    }
4466    0
4467}
4468
4469/// SIGWINCH flag — set by the signal handler, cleared after re-render.
4470static SIGWINCH_RECEIVED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
4471
4472/// Bare signal handler — just sets the atomic flag.
4473#[cfg(unix)]
4474extern "C" fn sigwinch_handler(_sig: libc::c_int) {
4475    SIGWINCH_RECEIVED.store(true, std::sync::atomic::Ordering::Relaxed);
4476}
4477
4478#[allow(non_snake_case)]
4479#[derive(Clone, Copy)]
4480struct DocTheme<'a> {
4481    C: &'a str,
4482    G: &'a str,
4483    Y: &'a str,
4484    M: &'a str,
4485    B: &'a str,
4486    D: &'a str,
4487    N: &'a str,
4488}
4489
4490/// The interactive full-screen pager loop.
4491#[cfg(unix)]
4492fn doc_interactive_loop(
4493    pages: &[(String, String, Vec<usize>)],
4494    entries: &[(&str, &str, String)],
4495    intro_raw: &str,
4496    start: usize,
4497    total: usize,
4498    theme: DocTheme,
4499) -> i32 {
4500    let DocTheme {
4501        C, G, M, B, D, N, ..
4502    } = theme;
4503    use std::os::unix::io::AsRawFd;
4504
4505    let stdin_fd = io::stdin().as_raw_fd();
4506    // Save terminal state and enter raw mode
4507    let mut old_termios: libc::termios = unsafe { std::mem::zeroed() };
4508    unsafe { libc::tcgetattr(stdin_fd, &mut old_termios) };
4509    let mut raw = old_termios;
4510    unsafe { libc::cfmakeraw(&mut raw) };
4511    unsafe { libc::tcsetattr(stdin_fd, libc::TCSANOW, &raw) };
4512
4513    // Install SIGWINCH handler
4514    let old_sigwinch = unsafe {
4515        libc::signal(
4516            libc::SIGWINCH,
4517            sigwinch_handler as *const () as libc::sighandler_t,
4518        )
4519    };
4520
4521    // Mutable — rebuilt on terminal resize
4522    let mut pages = pages.to_vec();
4523    let mut total = total;
4524    let mut current: usize = start;
4525
4526    // In raw mode, \n doesn't do \r\n — use this macro for every output line.
4527    macro_rules! rprint {
4528        () => { print!("\r\n"); };
4529        ($($arg:tt)*) => { print!("{}\r\n", format!($($arg)*)); };
4530    }
4531
4532    let render = |cur: usize, pages: &[(String, String, Vec<usize>)], total: usize| {
4533        let (ref cat, ref content, ref indices) = pages[cur];
4534        // Build topic list for status line
4535        let topic_list: String = indices
4536            .iter()
4537            .take(3)
4538            .map(|&i| entries[i].1)
4539            .collect::<Vec<_>>()
4540            .join(", ");
4541        let topic_display = if indices.len() > 3 {
4542            format!("{} +{}", topic_list, indices.len() - 3)
4543        } else {
4544            topic_list
4545        };
4546        let term_h = term_height();
4547
4548        // Clear entire screen
4549        print!("\x1b[H\x1b[2J");
4550
4551        // ── Header (rows 1-11, absolute positioned) ──
4552        print!("\x1b[1;1H"); // row 1
4553        rprint!();
4554        rprint!(" {C}███████╗████████╗██████╗ ██╗   ██╗██╗  ██╗███████╗{N}");
4555        rprint!(" {C}██╔════╝╚══██╔══╝██╔══██╗╚██╗ ██╔╝██║ ██╔╝██╔════╝{N}");
4556        rprint!(" {M}███████╗   ██║   ██████╔╝ ╚████╔╝ █████╔╝ █████╗  {N}");
4557        rprint!(" {M}╚════██║   ██║   ██╔══██╗  ╚██╔╝  ██╔═██╗ ██╔══╝  {N}");
4558        rprint!(" {C}███████║   ██║   ██║  ██║   ██║   ██║  ██╗███████╗{N}");
4559        rprint!(" {C}╚══════╝   ╚═╝   ╚═╝  ╚═╝   ╚═╝   ╚═╝  ╚═╝╚══════╝{N}");
4560        // Status box
4561        print!(" {D}┌");
4562        for _ in 0..74 {
4563            print!("─");
4564        }
4565        print!("┐{N}\r\n");
4566        let status = format!(
4567            " {G}{:>3}/{}{N}  {D}//{N} {C}{}{N}  {D}//{N} {M}{}{N}",
4568            cur + 1,
4569            total,
4570            topic_display,
4571            cat,
4572        );
4573        let vis_len = strip_ansi_len(&status);
4574        let pad = 74_usize.saturating_sub(vis_len);
4575        print!(" {D}│{N}{status}{:>pad$}{D}│{N}\r\n", "", pad = pad);
4576        print!(" {D}└");
4577        for _ in 0..74 {
4578            print!("─");
4579        }
4580        print!("┘{N}\r\n");
4581
4582        // ── Content (row 12 onward, truncated to fit above footer) ──
4583        let content_start = 12;
4584        let footer_rows = 3; // separator + keybindings + prompt
4585        let max_content = if term_h > content_start + footer_rows {
4586            term_h - content_start - footer_rows
4587        } else {
4588            1
4589        };
4590        print!("\x1b[{};1H", content_start);
4591        for (li, line) in content.lines().enumerate() {
4592            if li >= max_content {
4593                break; // truncate — don't scroll past footer
4594            }
4595            print!("{line}\r\n");
4596        }
4597
4598        // ── Footer (pinned to last 3 rows, absolute positioned) ──
4599        print!("\x1b[{};1H", term_h - 2);
4600        print!("  {D}");
4601        for _ in 0..76 {
4602            print!("─");
4603        }
4604        print!("{N}\r\n");
4605        print!("  {C}j{N}/{C}n{N} next  {C}k{N}/{C}p{N} prev  {C}d{N}/{C}u{N} ±5  {C}]{N}/{C}[{N} chapter  {C}t{N} toc  {C}/{N} search  {C}:{N}num  {C}r{N} rand  {C}?{N} help  {C}q{N} quit\r\n");
4606        print!("  {D}>>>{N} ");
4607        let _ = io::stdout().flush();
4608    };
4609
4610    render(current, &pages, total);
4611
4612    loop {
4613        let mut buf = [0u8; 1];
4614        let nread = unsafe { libc::read(stdin_fd, buf.as_mut_ptr() as *mut libc::c_void, 1) };
4615        if nread != 1 {
4616            // SIGWINCH — rebuild pages for new terminal height, then re-render
4617            if SIGWINCH_RECEIVED.swap(false, std::sync::atomic::Ordering::Relaxed) {
4618                let entry_idx = pages[current].2.first().copied().unwrap_or(0);
4619                let th = term_height();
4620                let content_area = th.saturating_sub(14).max(4);
4621                let mut rebuilt = build_fixed_pages(entries, content_area);
4622                let intro_page = pad_to_height(intro_raw, content_area);
4623                rebuilt.insert(0, ("Introduction".to_string(), intro_page, Vec::new()));
4624                pages = rebuilt;
4625                total = pages.len();
4626                current = if entry_idx == 0 && current == 0 {
4627                    0
4628                } else {
4629                    find_page_for_entry(&pages, entry_idx).min(total - 1)
4630                };
4631                render(current, &pages, total);
4632                continue;
4633            }
4634            break;
4635        }
4636        let key = buf[0];
4637        match key {
4638            // Next: j n l space enter
4639            b'j' | b'n' | b'l' | b' ' | b'\n' | b'\r' if current < total - 1 => {
4640                current += 1;
4641            }
4642            // Prev: k p h
4643            b'k' | b'p' | b'h' => {
4644                current = current.saturating_sub(1);
4645            }
4646            // First: g 0
4647            b'g' | b'0' => current = 0,
4648            // Last: G $
4649            b'G' | b'$' => current = total - 1,
4650            // Forward 5: d
4651            b'd' => {
4652                current = (current + 5).min(total - 1);
4653            }
4654            // Back 5: u
4655            b'u' => {
4656                current = current.saturating_sub(5);
4657            }
4658            // Next chapter: ] }
4659            b']' | b'}' => {
4660                let cur_cat = &pages[current].0;
4661                while current < total - 1 {
4662                    current += 1;
4663                    if pages[current].0 != *cur_cat {
4664                        break;
4665                    }
4666                }
4667            }
4668            // Prev chapter: [ {
4669            b'[' | b'{' => {
4670                let cur_cat = pages[current].0.clone();
4671                while current > 0 {
4672                    current -= 1;
4673                    if pages[current].0 != cur_cat {
4674                        break;
4675                    }
4676                }
4677            }
4678            // Random: r
4679            b'r' => {
4680                current = rand::thread_rng().gen_range(0..total);
4681            }
4682            // TOC: t
4683            b't' => {
4684                // Restore cooked mode for line input
4685                unsafe { libc::tcsetattr(stdin_fd, libc::TCSANOW, &old_termios) };
4686                print!("\x1b[H\x1b[2J");
4687                doc_print_toc_entries(entries, &pages, theme);
4688                print!("  {D}enter page number or press enter to return >>>{N} ");
4689                let _ = io::stdout().flush();
4690                let mut line = String::new();
4691                let _ = io::stdin().read_line(&mut line);
4692                if let Ok(n) = line.trim().parse::<usize>() {
4693                    if n >= 1 && n <= total {
4694                        current = n - 1;
4695                    }
4696                }
4697                unsafe { libc::tcsetattr(stdin_fd, libc::TCSANOW, &raw) };
4698            }
4699            // Search: /
4700            b'/' => {
4701                unsafe { libc::tcsetattr(stdin_fd, libc::TCSANOW, &old_termios) };
4702                print!("\r  {C}/{N}");
4703                let _ = io::stdout().flush();
4704                let mut line = String::new();
4705                let _ = io::stdin().read_line(&mut line);
4706                let pat = line.trim().to_lowercase();
4707                if !pat.is_empty() {
4708                    // Search forward from current page
4709                    let start_from = (current + 1) % total;
4710                    let mut found = false;
4711                    for i in 0..total {
4712                        let idx = (start_from + i) % total;
4713                        let (ref cat, ref content, _) = pages[idx];
4714                        if cat.to_lowercase().contains(&pat)
4715                            || content.to_lowercase().contains(&pat)
4716                        {
4717                            current = idx;
4718                            found = true;
4719                            break;
4720                        }
4721                    }
4722                    let _ = found; // overwritten by render
4723                }
4724                unsafe { libc::tcsetattr(stdin_fd, libc::TCSANOW, &raw) };
4725            }
4726            // Goto: :
4727            b':' => {
4728                unsafe { libc::tcsetattr(stdin_fd, libc::TCSANOW, &old_termios) };
4729                print!("\r  {C}:{N}");
4730                let _ = io::stdout().flush();
4731                let mut line = String::new();
4732                let _ = io::stdin().read_line(&mut line);
4733                if let Ok(n) = line.trim().parse::<usize>() {
4734                    if n >= 1 && n <= total {
4735                        current = n - 1;
4736                    }
4737                }
4738                unsafe { libc::tcsetattr(stdin_fd, libc::TCSANOW, &raw) };
4739            }
4740            // Help: ?
4741            b'?' => {
4742                print!("\x1b[H\x1b[2J");
4743                rprint!();
4744                rprint!("  {D}── KEYBINDINGS ────────────────────────────────────────────────────{N}");
4745                rprint!();
4746                rprint!("  {B}Navigation{N}");
4747                rprint!("  {C}j n l space enter{N}    {D}next page{N}");
4748                rprint!("  {C}k p h{N}                {D}previous page{N}");
4749                rprint!("  {C}d{N}                    {D}forward 5 pages{N}");
4750                rprint!("  {C}u{N}                    {D}back 5 pages{N}");
4751                rprint!("  {C}g 0{N}                  {D}first page{N}");
4752                rprint!("  {C}G ${N}                  {D}last page{N}");
4753                rprint!("  {C}] }}{N}                  {D}next chapter{N}");
4754                rprint!("  {C}[ {{{N}                  {D}previous chapter{N}");
4755                rprint!();
4756                rprint!("  {B}Search & Jump{N}");
4757                rprint!("  {C}/{N}                    {D}search pages{N}");
4758                rprint!("  {C}:{N}                    {D}go to page number{N}");
4759                rprint!("  {C}t{N}                    {D}table of contents{N}");
4760                rprint!("  {C}r{N}                    {D}random page{N}");
4761                rprint!();
4762                rprint!("  {B}Other{N}");
4763                rprint!("  {C}?{N}                    {D}this help{N}");
4764                rprint!("  {C}q Q{N}                  {D}quit{N}");
4765                rprint!();
4766                rprint!("  {D}press any key to return{N}");
4767                let _ = io::stdout().flush();
4768                let mut b2 = [0u8; 1];
4769                let _ = unsafe { libc::read(stdin_fd, b2.as_mut_ptr() as *mut _, 1) };
4770            }
4771            // Quit: q Q
4772            b'q' | b'Q' | 0x03 /* ctrl-c */ => {
4773                break;
4774            }
4775            _ => {}
4776        }
4777        render(current, &pages, total);
4778    }
4779
4780    // Restore terminal and SIGWINCH handler
4781    unsafe { libc::signal(libc::SIGWINCH, old_sigwinch) };
4782    unsafe { libc::tcsetattr(stdin_fd, libc::TCSANOW, &old_termios) };
4783    print!("\x1b[H\x1b[2J");
4784    let _ = io::stdout().flush();
4785    0
4786}
4787
4788#[cfg(not(unix))]
4789fn doc_interactive_loop(
4790    pages: &[(String, String, Vec<usize>)],
4791    _entries: &[(&str, &str, String)],
4792    _intro_raw: &str,
4793    start: usize,
4794    _total: usize,
4795    _theme: DocTheme,
4796) -> i32 {
4797    // Fallback: just print the starting page
4798    print!("{}", pages[start].1);
4799    0
4800}
4801
4802fn term_height() -> usize {
4803    #[cfg(unix)]
4804    {
4805        let mut ws = libc::winsize {
4806            ws_row: 0,
4807            ws_col: 0,
4808            ws_xpixel: 0,
4809            ws_ypixel: 0,
4810        };
4811        if unsafe { libc::ioctl(2, libc::TIOCGWINSZ, &mut ws) } == 0 && ws.ws_row > 0 {
4812            return ws.ws_row as usize;
4813        }
4814    }
4815    24
4816}
4817
4818fn term_width() -> usize {
4819    #[cfg(unix)]
4820    {
4821        let mut ws = libc::winsize {
4822            ws_row: 0,
4823            ws_col: 0,
4824            ws_xpixel: 0,
4825            ws_ypixel: 0,
4826        };
4827        if unsafe { libc::ioctl(2, libc::TIOCGWINSZ, &mut ws) } == 0 && ws.ws_col > 0 {
4828            return ws.ws_col as usize;
4829        }
4830    }
4831    80
4832}
4833
4834fn strip_ansi_len(s: &str) -> usize {
4835    let mut len = 0;
4836    let mut in_esc = false;
4837    for c in s.chars() {
4838        if c == '\x1b' {
4839            in_esc = true;
4840        } else if in_esc {
4841            if c == 'm' {
4842                in_esc = false;
4843            }
4844        } else {
4845            len += 1;
4846        }
4847    }
4848    len
4849}
4850
4851fn doc_print_banner(theme: DocTheme) {
4852    let DocTheme { C, M, N, .. } = theme;
4853    println!(" {C}███████╗████████╗██████╗ ██╗   ██╗██╗  ██╗███████╗{N}");
4854    println!(" {C}██╔════╝╚══██╔══╝██╔══██╗╚██╗ ██╔╝██║ ██╔╝██╔════╝{N}");
4855    println!(" {M}███████╗   ██║   ██████╔╝ ╚████╔╝ █████╔╝ █████╗  {N}");
4856    println!(" {M}╚════██║   ██║   ██╔══██╗  ╚██╔╝  ██╔═██╗ ██╔══╝  {N}");
4857    println!(" {C}███████║   ██║   ██║  ██║   ██║   ██║  ██╗███████╗{N}");
4858    println!(" {C}╚══════╝   ╚═╝   ╚═╝  ╚═╝   ╚═╝   ╚═╝  ╚═╝╚══════╝{N}");
4859}
4860
4861fn doc_print_hline(left: char, right: char, theme: DocTheme) {
4862    let DocTheme { D, N, .. } = theme;
4863    print!(" {D}{left}");
4864    for _ in 0..74 {
4865        print!("─");
4866    }
4867    println!("{right}{N}");
4868}
4869
4870fn doc_print_boxline(content: &str, theme: DocTheme) {
4871    let DocTheme { D, N, .. } = theme;
4872    // Strip ANSI to measure visible width
4873    let stripped = content
4874        .bytes()
4875        .fold((Vec::new(), false), |(mut acc, in_esc), b| {
4876            if b == 0x1b {
4877                (acc, true)
4878            } else if in_esc {
4879                (acc, b != b'm')
4880            } else {
4881                acc.push(b);
4882                (acc, false)
4883            }
4884        })
4885        .0;
4886    let visible = String::from_utf8_lossy(&stripped).chars().count();
4887    let inner: usize = 74;
4888    let pad = inner.saturating_sub(visible);
4889    println!(" {D}│{N}{content}{:>pad$}{D}│{N}", "", pad = pad);
4890}
4891
4892fn doc_print_separator(label: &str, theme: DocTheme) {
4893    let DocTheme { D, N, .. } = theme;
4894    let trail = 72usize.saturating_sub(label.len());
4895    print!("  {D}── {label} ");
4896    for _ in 0..trail {
4897        print!("─");
4898    }
4899    println!("{N}");
4900}
4901
4902fn doc_print_toc_entries(
4903    entries: &[(&str, &str, String)],
4904    pages: &[(String, String, Vec<usize>)],
4905    theme: DocTheme,
4906) {
4907    let DocTheme {
4908        C, G, M, B, D, N, ..
4909    } = theme;
4910    let topic_count = entries.len();
4911    let page_count = pages.len();
4912    println!();
4913    doc_print_banner(theme);
4914    doc_print_hline('┌', '┐', theme);
4915    doc_print_boxline(
4916        &format!(
4917            " {G}TABLE OF CONTENTS{N}  {D}//{N} {C}{topic_count} topics, {page_count} pages{N}  {D}//{N} {M}The stryke Encyclopedia{N}"
4918        ),
4919        theme,
4920    );
4921    doc_print_hline('└', '┘', theme);
4922    println!();
4923    let mut last_cat = "";
4924    for (entry_idx, (cat, topic, _)) in entries.iter().enumerate() {
4925        if *cat != last_cat {
4926            println!();
4927            println!("  {B}{cat}{N}");
4928            last_cat = cat;
4929        }
4930        // Find which page this entry is on (skip intro page at index 0)
4931        let page_num = pages
4932            .iter()
4933            .position(|(_, _, indices)| indices.contains(&entry_idx))
4934            .map(|p| p + 1)
4935            .unwrap_or(0);
4936        println!(
4937            "    {C}{:>3}.{N} {:<30} {D}p.{}{N}",
4938            entry_idx + 1,
4939            topic,
4940            page_num
4941        );
4942    }
4943    println!();
4944}
4945
4946/// Word-wrap a plain-text line at `max_vis` visible characters.
4947/// Returns wrapped lines (without leading indent — caller adds it).
4948/// ANSI escapes are not counted toward visible width.
4949fn word_wrap(text: &str, max_vis: usize) -> Vec<String> {
4950    if max_vis == 0 {
4951        return vec![text.to_string()];
4952    }
4953    let mut lines: Vec<String> = Vec::new();
4954    let mut cur = String::new();
4955    let mut vis = 0usize;
4956
4957    for word in text.split(' ') {
4958        let wvis = strip_ansi_len(word);
4959        if vis > 0 && vis + 1 + wvis > max_vis {
4960            // wrap
4961            lines.push(cur);
4962            cur = word.to_string();
4963            vis = wvis;
4964        } else {
4965            if vis > 0 {
4966                cur.push(' ');
4967                vis += 1;
4968            }
4969            cur.push_str(word);
4970            vis += wvis;
4971        }
4972    }
4973    if !cur.is_empty() || lines.is_empty() {
4974        lines.push(cur);
4975    }
4976    lines
4977}
4978
4979/// Render a single page's content (without banner/chrome).
4980/// Prose lines are word-wrapped at 76 visible columns (80 - 2*indent).
4981/// Code lines are kept as-is (indented 4 spaces).
4982#[allow(non_snake_case)]
4983fn render_page_content(topic: &str, text: &str, C: &str, G: &str, D: &str, N: &str) -> String {
4984    let max_vis = term_width().saturating_sub(4).max(40); // width - 2 indent - 2 margin
4985    let mut out = String::with_capacity(text.len() + 512);
4986    out.push_str(&format!("  {C}{topic}{N}\n"));
4987    out.push_str(&format!(
4988        "  {D}{}{N}\n",
4989        "─".repeat(topic.len().max(20).min(max_vis))
4990    ));
4991    let mut in_code = false;
4992    for line in text.split('\n') {
4993        if line.starts_with("```") {
4994            in_code = !in_code;
4995            continue;
4996        }
4997        if in_code {
4998            out.push_str(&format!("  {G}  {line}{N}\n"));
4999        } else if line.trim().is_empty() {
5000            out.push('\n');
5001        } else {
5002            let rendered = render_inline_code(line, C, N);
5003            for wrapped in word_wrap(&rendered, max_vis) {
5004                out.push_str(&format!("  {wrapped}\n"));
5005            }
5006        }
5007    }
5008    out
5009}
5010
5011/// Replace `backtick` spans with colored versions for terminal output.
5012fn render_inline_code(line: &str, color: &str, reset: &str) -> String {
5013    let mut out = String::with_capacity(line.len() + 64);
5014    let mut in_tick = false;
5015    for ch in line.chars() {
5016        if ch == '`' {
5017            if in_tick {
5018                out.push_str(reset);
5019            } else {
5020                out.push_str(color);
5021            }
5022            in_tick = !in_tick;
5023        } else {
5024            out.push(ch);
5025        }
5026    }
5027    out
5028}
5029
5030/// `stryke deconvert FILE...` — convert stryke .stk files back to standard Perl .pl syntax.
5031fn run_deconvert_subcommand(args: &[String]) -> i32 {
5032    let mut files: Vec<String> = Vec::new();
5033    let mut in_place = false;
5034    let mut output_delim: Option<char> = None;
5035    let mut i = 0;
5036    while i < args.len() {
5037        match args[i].as_str() {
5038            "-i" | "--in-place" => in_place = true,
5039            "-d" | "--output-delim" => {
5040                i += 1;
5041                if i >= args.len() {
5042                    eprintln!("stryke deconvert: --output-delim requires an argument");
5043                    return 2;
5044                }
5045                let delim_str = &args[i];
5046                if delim_str.chars().count() != 1 {
5047                    eprintln!(
5048                        "stryke deconvert: --output-delim must be a single character, got {:?}",
5049                        delim_str
5050                    );
5051                    return 2;
5052                }
5053                output_delim = delim_str.chars().next();
5054            }
5055            "-h" | "--help" => {
5056                println!("usage: stryke deconvert [-i] [-d DELIM] FILE...");
5057                println!();
5058                println!("Convert stryke .stk files back to standard Perl .pl syntax:");
5059                println!("  - Pipe chains and thread macros → nested function calls");
5060                println!("  - fn → sub");
5061                println!("  - p → say");
5062                println!("  - Adds trailing semicolons");
5063                println!("  - #!/usr/bin/env perl shebang prepended");
5064                println!();
5065                println!("Options:");
5066                println!("  -i, --in-place       Write .pl files alongside originals");
5067                println!("  -d, --output-delim   Delimiter for s///, tr///, m// (default: preserve original)");
5068                println!();
5069                println!("Examples:");
5070                println!("  stryke deconvert app.stk             # print to stdout");
5071                println!("  stryke deconvert -i lib/*.stk        # write lib/*.pl");
5072                println!(
5073                    "  stryke deconvert -d '|' app.stk      # use | as delimiter: s|old|new|g"
5074                );
5075                return 0;
5076            }
5077            s if s.starts_with('-') => {
5078                eprintln!("stryke deconvert: unknown option: {}", s);
5079                eprintln!("usage: stryke deconvert [-i] [-d DELIM] FILE...");
5080                return 2;
5081            }
5082            s => files.push(s.to_string()),
5083        }
5084        i += 1;
5085    }
5086    if files.is_empty() {
5087        eprintln!("stryke deconvert: no input files");
5088        eprintln!("usage: stryke deconvert [-i] [-d DELIM] FILE...");
5089        return 2;
5090    }
5091    let opts = crate::deconvert::DeconvertOptions { output_delim };
5092    let mut errors = 0;
5093    for f in &files {
5094        let code = match std::fs::read_to_string(f) {
5095            Ok(c) => c,
5096            Err(e) => {
5097                eprintln!("stryke deconvert: {}: {}", f, e);
5098                errors += 1;
5099                continue;
5100            }
5101        };
5102        let program = match crate::parse_with_file(&code, f) {
5103            Ok(p) => p,
5104            Err(e) => {
5105                eprintln!("stryke deconvert: {}: {}", f, e);
5106                errors += 1;
5107                continue;
5108            }
5109        };
5110        let deconverted = crate::deconvert_to_perl_with_options(&program, &opts);
5111        if in_place {
5112            let out_path = std::path::Path::new(f).with_extension("pl");
5113            if let Err(e) = std::fs::write(&out_path, &deconverted) {
5114                eprintln!("stryke deconvert: {}: {}", out_path.display(), e);
5115                errors += 1;
5116            }
5117        } else {
5118            println!("{}", deconverted);
5119        }
5120    }
5121    if errors > 0 {
5122        1
5123    } else {
5124        0
5125    }
5126}
5127
5128/// Strip shebang line; if extract mode (-x), skip everything until #!...perl line.
5129fn strip_shebang_and_extract(content: &str, extract: bool) -> String {
5130    if extract {
5131        // -x: skip lines until we find one starting with #! and containing "perl"
5132        let mut found = false;
5133        let mut lines = Vec::new();
5134        for line in content.lines() {
5135            if !found {
5136                if line.starts_with("#!") && line.contains("perl") {
5137                    found = true;
5138                    // Don't include the shebang line itself
5139                }
5140                continue;
5141            }
5142            // Stop at __END__ or __DATA__
5143            if line == "__END__" || line == "__DATA__" {
5144                break;
5145            }
5146            lines.push(line);
5147        }
5148        lines.join("\n")
5149    } else if content.starts_with("#!") {
5150        if let Some(pos) = content.find('\n') {
5151            content[pos + 1..].to_string()
5152        } else {
5153            String::new()
5154        }
5155    } else {
5156        content.to_string()
5157    }
5158}
5159
5160/// `stryke fmt [-i] [-w WIDTH] FILE...` — format stryke source files.
5161fn run_fmt_subcommand(args: &[String]) -> i32 {
5162    let mut files: Vec<String> = Vec::new();
5163    let mut in_place = false;
5164    let mut i = 0;
5165    while i < args.len() {
5166        match args[i].as_str() {
5167            "-i" | "--in-place" => in_place = true,
5168            "-h" | "--help" => {
5169                println!("usage: stryke fmt [-i] FILE...");
5170                println!();
5171                println!("Format stryke source files (parse → pretty-print).");
5172                println!();
5173                println!("Options:");
5174                println!("  -i, --in-place   Rewrite files in place (default: print to stdout)");
5175                println!();
5176                println!("Examples:");
5177                println!("  stryke fmt app.stk              # print formatted source to stdout");
5178                println!("  stryke fmt -i lib/*.stk          # rewrite files in place");
5179                println!("  stryke fmt -i .                  # format all .stk files recursively");
5180                return 0;
5181            }
5182            s if s.starts_with('-') => {
5183                eprintln!("stryke fmt: unknown option: {}", s);
5184                eprintln!("usage: stryke fmt [-i] FILE...");
5185                return 2;
5186            }
5187            s => files.push(s.to_string()),
5188        }
5189        i += 1;
5190    }
5191    if files.is_empty() {
5192        eprintln!("stryke fmt: no input files");
5193        eprintln!("usage: stryke fmt [-i] FILE...");
5194        return 2;
5195    }
5196    // Expand directory arguments: recursively collect .stk/.pl/.pm files.
5197    let mut expanded: Vec<String> = Vec::new();
5198    for f in &files {
5199        let p = std::path::Path::new(f);
5200        if p.is_dir() {
5201            collect_stryke_files(p, &mut expanded);
5202        } else {
5203            expanded.push(f.clone());
5204        }
5205    }
5206    if expanded.is_empty() {
5207        eprintln!("stryke fmt: no .stk/.pl/.pm files found");
5208        return 1;
5209    }
5210    let mut errors = 0;
5211    for f in &expanded {
5212        let code = match std::fs::read_to_string(f) {
5213            Ok(c) => c,
5214            Err(e) => {
5215                eprintln!("stryke fmt: {}: {}", f, e);
5216                errors += 1;
5217                continue;
5218            }
5219        };
5220        let program = match crate::parse_with_file(&code, f) {
5221            Ok(p) => p,
5222            Err(e) => {
5223                eprintln!("stryke fmt: {}: {}", f, e);
5224                errors += 1;
5225                continue;
5226            }
5227        };
5228        let formatted = crate::convert_to_stryke(&program);
5229        if in_place {
5230            if formatted == code {
5231                continue; // already formatted
5232            }
5233            if let Err(e) = std::fs::write(f, &formatted) {
5234                eprintln!("stryke fmt: {}: {}", f, e);
5235                errors += 1;
5236            } else {
5237                eprintln!("  formatted {}", f);
5238            }
5239        } else {
5240            print!("{}", formatted);
5241        }
5242    }
5243    if errors > 0 {
5244        1
5245    } else {
5246        0
5247    }
5248}
5249
5250/// `stryke minify FILE...` — strip comments / POD / extraneous whitespace,
5251/// collapse statements onto a single line with `;` separators. Result is
5252/// still valid stryke source that parses to the same AST as the input.
5253fn run_minify_subcommand(args: &[String]) -> i32 {
5254    let mut files: Vec<String> = Vec::new();
5255    let mut in_place = false;
5256    let mut i = 0;
5257    while i < args.len() {
5258        match args[i].as_str() {
5259            "-i" | "--in-place" => in_place = true,
5260            "-h" | "--help" => {
5261                println!("usage: stryke minify [-i] FILE...");
5262                println!();
5263                println!("Minify stryke source: strip comments / POD / blank lines, collapse");
5264                println!("statements onto a single line with `;` separators. Output parses");
5265                println!("identically to the input.");
5266                println!();
5267                println!("Options:");
5268                println!("  -i, --in-place   Rewrite files in place (default: print to stdout)");
5269                println!();
5270                println!("Examples:");
5271                println!("  stryke minify app.stk              # print minified source to stdout");
5272                println!("  stryke minify -i lib/*.stk          # rewrite files in place");
5273                println!(
5274                    "  stryke minify -i .                  # minify all .stk files recursively"
5275                );
5276                return 0;
5277            }
5278            s if s.starts_with('-') => {
5279                eprintln!("stryke minify: unknown option: {}", s);
5280                eprintln!("usage: stryke minify [-i] FILE...");
5281                return 2;
5282            }
5283            s => files.push(s.to_string()),
5284        }
5285        i += 1;
5286    }
5287    if files.is_empty() {
5288        eprintln!("stryke minify: no input files");
5289        eprintln!("usage: stryke minify [-i] FILE...");
5290        return 2;
5291    }
5292    let mut expanded: Vec<String> = Vec::new();
5293    for f in &files {
5294        let p = std::path::Path::new(f);
5295        if p.is_dir() {
5296            collect_stryke_files(p, &mut expanded);
5297        } else {
5298            expanded.push(f.clone());
5299        }
5300    }
5301    if expanded.is_empty() {
5302        eprintln!("stryke minify: no .stk/.pl/.pm files found");
5303        return 1;
5304    }
5305    let mut errors = 0;
5306    for f in &expanded {
5307        let code = match std::fs::read_to_string(f) {
5308            Ok(c) => c,
5309            Err(e) => {
5310                eprintln!("stryke minify: {}: {}", f, e);
5311                errors += 1;
5312                continue;
5313            }
5314        };
5315        let minified = match crate::minify::minify_source(&code) {
5316            Ok(s) => s,
5317            Err(e) => {
5318                eprintln!("stryke minify: {}: {}", f, e);
5319                errors += 1;
5320                continue;
5321            }
5322        };
5323        if in_place {
5324            if minified == code {
5325                continue;
5326            }
5327            if let Err(e) = std::fs::write(f, &minified) {
5328                eprintln!("stryke minify: {}: {}", f, e);
5329                errors += 1;
5330            } else {
5331                eprintln!(
5332                    "  minified {} ({} → {} bytes)",
5333                    f,
5334                    code.len(),
5335                    minified.len()
5336                );
5337            }
5338        } else {
5339            println!("{}", minified);
5340        }
5341    }
5342    if errors > 0 {
5343        1
5344    } else {
5345        0
5346    }
5347}
5348
5349/// Recursively collect `.stk`, `.pl`, `.pm` files from a directory.
5350fn collect_stryke_files(dir: &std::path::Path, out: &mut Vec<String>) {
5351    let Ok(entries) = std::fs::read_dir(dir) else {
5352        return;
5353    };
5354    let mut paths: Vec<std::path::PathBuf> =
5355        entries.filter_map(|e| e.ok()).map(|e| e.path()).collect();
5356    paths.sort();
5357    for p in paths {
5358        if p.is_dir() {
5359            // Skip hidden dirs and common noise.
5360            let name = p
5361                .file_name()
5362                .map(|n| n.to_string_lossy().to_string())
5363                .unwrap_or_default();
5364            if name.starts_with('.') || name == "target" || name == "node_modules" {
5365                continue;
5366            }
5367            collect_stryke_files(&p, out);
5368        } else if let Some(ext) = p.extension() {
5369            let ext = ext.to_string_lossy();
5370            if ext == "stk" || ext == "pl" || ext == "pm" {
5371                out.push(p.to_string_lossy().to_string());
5372            }
5373        }
5374    }
5375}
5376
5377/// `stryke bench [FILE|DIR]` — discover and run benchmark files with timing.
5378fn run_bench_subcommand(argv0: &str, args: &[String]) -> i32 {
5379    if !args.is_empty() && (args[0] == "-h" || args[0] == "--help") {
5380        println!("usage: stryke bench [FILE|DIR]");
5381        println!();
5382        println!("Discover and run benchmark files. Looks for bench_*.stk / b_*.stk");
5383        println!("in bench/ or benches/ directories (or a specified path).");
5384        println!();
5385        println!("Each file is run and timed. Use the `bench {{ }}` builtin inside");
5386        println!("files for micro-benchmarks with iteration counts and ops/sec.");
5387        println!();
5388        println!("Examples:");
5389        println!("  stryke bench                    # auto-discover bench/ or benches/");
5390        println!("  stryke bench bench/bench_sort.stk  # run a single benchmark");
5391        println!("  stryke bench benches/            # run all in a directory");
5392        return 0;
5393    }
5394    let target = if !args.is_empty() {
5395        args[0].clone()
5396    } else if std::path::Path::new("bench").is_dir() {
5397        "bench".to_string()
5398    } else if std::path::Path::new("benches").is_dir() {
5399        "benches".to_string()
5400    } else {
5401        eprintln!("stryke bench: no bench/ or benches/ directory found");
5402        return 1;
5403    };
5404    let target_path = std::path::Path::new(&target);
5405    let bench_files: Vec<String> = if target_path.is_dir() {
5406        let mut files: Vec<String> = std::fs::read_dir(target_path)
5407            .unwrap_or_else(|e| {
5408                eprintln!("stryke bench: {}: {}", target, e);
5409                crate::hosted::exit(1);
5410            })
5411            .filter_map(|e| e.ok())
5412            .map(|e| e.path().to_string_lossy().to_string())
5413            .filter(|p| {
5414                let name = std::path::Path::new(p)
5415                    .file_name()
5416                    .map(|n| n.to_string_lossy().to_string())
5417                    .unwrap_or_default();
5418                (name.starts_with("bench_") || name.starts_with("b_"))
5419                    && (name.ends_with(".stk") || name.ends_with(".st") || name.ends_with(".pl"))
5420            })
5421            .collect();
5422        files.sort();
5423        files
5424    } else {
5425        vec![target]
5426    };
5427    if bench_files.is_empty() {
5428        eprintln!("stryke bench: no benchmark files found (bench_*.stk or b_*.stk)");
5429        return 1;
5430    }
5431    let total = bench_files.len();
5432    let mut failed = 0;
5433    eprintln!(
5434        "\x1b[36mRunning {} benchmark{}\x1b[0m\n",
5435        total,
5436        if total == 1 { "" } else { "s" }
5437    );
5438    let exe = std::env::current_exe()
5439        .ok()
5440        .filter(|p| p.exists())
5441        .or_else(|| std::fs::canonicalize(argv0).ok())
5442        .unwrap_or_else(|| PathBuf::from(argv0));
5443    for f in &bench_files {
5444        let name = std::path::Path::new(f)
5445            .file_name()
5446            .map(|n| n.to_string_lossy().to_string())
5447            .unwrap_or_else(|| f.clone());
5448        eprint!("\x1b[1m── {} ──\x1b[0m ", name);
5449        let script_abs = std::fs::canonicalize(f).unwrap_or_else(|_| PathBuf::from(f));
5450        let project_root = script_abs
5451            .parent()
5452            .and_then(|p| p.parent())
5453            .unwrap_or(std::path::Path::new("."));
5454        let start = std::time::Instant::now();
5455        let output = process::Command::new(&exe)
5456            .arg(&script_abs)
5457            .args(args.get(1..).unwrap_or(&[]))
5458            .current_dir(project_root)
5459            .stderr(process::Stdio::piped())
5460            .stdout(process::Stdio::piped())
5461            .output();
5462        let elapsed = start.elapsed();
5463        match output {
5464            Ok(out) => {
5465                let stderr = String::from_utf8_lossy(&out.stderr);
5466                let stdout = String::from_utf8_lossy(&out.stdout);
5467                if out.status.success() {
5468                    eprintln!("\x1b[32m{:.3}s\x1b[0m", elapsed.as_secs_f64());
5469                } else {
5470                    eprintln!("\x1b[31mFAILED ({:.3}s)\x1b[0m", elapsed.as_secs_f64());
5471                    failed += 1;
5472                }
5473                // Print benchmark output (stderr first, then stdout).
5474                if !stderr.is_empty() {
5475                    eprint!("{}", stderr);
5476                }
5477                if !stdout.is_empty() {
5478                    print!("{}", stdout);
5479                }
5480            }
5481            Err(e) => {
5482                eprintln!("\x1b[31mfailed to run: {}\x1b[0m", e);
5483                failed += 1;
5484            }
5485        }
5486        eprintln!();
5487    }
5488    eprintln!("═══════════════════════════════");
5489    if failed == 0 {
5490        eprintln!(
5491            "\x1b[32m✓ All {} benchmark{} completed\x1b[0m",
5492            total,
5493            if total == 1 { "" } else { "s" }
5494        );
5495        0
5496    } else {
5497        eprintln!(
5498            "\x1b[31m✗ {} of {} benchmark{} failed\x1b[0m",
5499            failed,
5500            total,
5501            if total == 1 { "" } else { "s" }
5502        );
5503        1
5504    }
5505}
5506
5507/// `stryke repl [--load FILE]` — explicit REPL entry with optional pre-load.
5508fn run_repl_subcommand(args: &[String]) -> i32 {
5509    let mut load_file: Option<String> = None;
5510    let mut i = 0;
5511    while i < args.len() {
5512        match args[i].as_str() {
5513            "--load" | "-l" => {
5514                i += 1;
5515                if i >= args.len() {
5516                    eprintln!("stryke repl: --load requires a file argument");
5517                    return 2;
5518                }
5519                load_file = Some(args[i].clone());
5520            }
5521            "-h" | "--help" => {
5522                println!("usage: stryke repl [--load FILE]");
5523                println!();
5524                println!("Start the interactive REPL (readline, history, tab completion).");
5525                println!();
5526                println!("Options:");
5527                println!("  -l, --load FILE  Evaluate FILE before entering the REPL");
5528                println!();
5529                println!("Examples:");
5530                println!("  stryke repl                   # start REPL");
5531                println!("  stryke repl --load lib.stk    # pre-load a library, then REPL");
5532                return 0;
5533            }
5534            other => {
5535                eprintln!("stryke repl: unknown option: {}", other);
5536                eprintln!("usage: stryke repl [--load FILE]");
5537                return 2;
5538            }
5539        }
5540        i += 1;
5541    }
5542    // Build a Cli struct for the REPL, optionally with a pre-load script.
5543    let mut cli = Cli::default();
5544    if let Some(ref path) = load_file {
5545        if !std::path::Path::new(path).exists() {
5546            eprintln!("stryke repl: file not found: {}", path);
5547            return 1;
5548        }
5549        // Use -e to pre-execute: `require "FILE";`
5550        cli.execute.push(format!("require {:?}", path));
5551    }
5552    repl::run(&cli);
5553    0
5554}
5555
5556/// Heuristic: does this string look like inline code rather than a filename?
5557/// Used for `stryke 'p 1+2'` (no `-e` needed).
5558fn looks_like_code(s: &str) -> bool {
5559    // Contains whitespace, Perl operators, or known statement starters
5560    s.contains(' ')
5561        || s.contains(';')
5562        || s.contains('|')
5563        || s.contains('{')
5564        || s.contains('(')
5565        || s.contains('$')
5566        || s.contains('@')
5567        || s.contains('>')
5568}
5569
5570/// Look for a script file in PATH (-S flag).
5571fn find_in_path(script: &str) -> Option<String> {
5572    if std::path::Path::new(script).is_absolute() || script.contains('/') {
5573        return Some(script.to_string());
5574    }
5575    if let Ok(path_var) = std::env::var("PATH") {
5576        for dir in path_var.split(':') {
5577            let full = format!("{}/{}", dir, script);
5578            if std::path::Path::new(&full).exists() {
5579                return Some(full);
5580            }
5581        }
5582    }
5583    None
5584}
5585
5586/// Print configuration summary (-V flag).
5587fn print_config(configvar: Option<&str>) {
5588    let version = env!("CARGO_PKG_VERSION");
5589    let arch = std::env::consts::ARCH;
5590    let os = std::env::consts::OS;
5591    let threads = std::thread::available_parallelism()
5592        .map(|n| n.get())
5593        .unwrap_or(1);
5594
5595    if let Some(var) = configvar {
5596        // Print a single config variable
5597        let val = match var {
5598            "version" | "api_version" => version.to_string(),
5599            "archname" => format!("{}-{}", arch, os),
5600            "osname" => os.to_string(),
5601            "threads" => threads.to_string(),
5602            "useithreads" | "usethreads" => "define".to_string(),
5603            "use64bitint" | "use64bitall" => "define".to_string(),
5604            "cc" => "rustc".to_string(),
5605            "optimize" => "-O3 -lto".to_string(),
5606            "prefix" | "installprefix" => "/usr/local".to_string(),
5607            "perlpath" => "stryke".to_string(),
5608            _ => {
5609                eprintln!("Unknown config variable: {}", var);
5610                return;
5611            }
5612        };
5613        println!("{}='{}'", var, val);
5614    } else {
5615        println!("Summary of stryke v{} configuration:\n", version);
5616        println!("  Platform:");
5617        println!("    osname={}, archname={}-{}", os, arch, os);
5618        println!("  Compiler:");
5619        println!("    cc=rustc, optimize=-O3 -lto");
5620        println!("  Threading:");
5621        println!("    useithreads=define, threads={}", threads);
5622        println!("  Integer/Float:");
5623        println!("    use64bitint=define, use64bitall=define");
5624        println!("  Parallel extensions:");
5625        println!("    rayon=define, pmap=define, pmap_chunked=define, pipeline=define, par_pipeline=define, async=define, await=define, pgrep=define, pfor=define, psort=define, reduce=define, preduce=define, preduce_init=define, jit=define");
5626        println!("  Install:");
5627        println!("    perlpath=stryke");
5628    }
5629}
5630
5631#[cfg(test)]
5632mod cli_argv_tests {
5633    use super::{expand_perl_bundled_argv, normalize_argv_after_dash_e, parse_cli_prelude, Cli};
5634    use clap::Parser;
5635
5636    fn args(v: &[&str]) -> Vec<String> {
5637        v.iter().map(|s| (*s).to_string()).collect()
5638    }
5639
5640    #[test]
5641    fn prelude_inserts_double_dash_before_script_argv_long_flags() {
5642        let a = args(&["stryke", "s.pl", "--regex", "--foo"]);
5643        let cli = parse_cli_prelude(&a).expect("expected prelude parse");
5644        assert_eq!(cli.script.as_deref(), Some("s.pl"));
5645        assert_eq!(cli.args, vec!["--regex".to_string(), "--foo".to_string()]);
5646    }
5647
5648    #[test]
5649    fn prelude_with_dash_w_before_script() {
5650        let a = args(&["stryke", "-w", "s.pl", "--regex"]);
5651        let cli = parse_cli_prelude(&a).expect("expected prelude parse");
5652        assert!(cli.warnings);
5653        assert_eq!(cli.script.as_deref(), Some("s.pl"));
5654        assert_eq!(cli.args, vec!["--regex".to_string()]);
5655    }
5656
5657    #[test]
5658    fn prelude_dash_e_then_argv_with_long_flag() {
5659        let a = args(&["stryke", "-e", "1", "foo", "--regex"]);
5660        let mut cli = parse_cli_prelude(&a).expect("expected prelude parse");
5661        normalize_argv_after_dash_e(&mut cli);
5662        assert_eq!(cli.execute, vec!["1"]);
5663        assert!(cli.script.is_none());
5664        assert_eq!(cli.args, vec!["foo".to_string(), "--regex".to_string()]);
5665    }
5666
5667    #[test]
5668    fn explicit_user_double_dash_skips_prelude() {
5669        let a = args(&["stryke", "--", "s.pl", "x"]);
5670        assert!(parse_cli_prelude(&a).is_none());
5671    }
5672
5673    #[test]
5674    fn bundled_lane_le_lne_maps_to_split_switches() {
5675        for (flag, code, expect_a, expect_n) in [
5676            ("-lane", "print 1", true, true),
5677            ("-le", "print 2", false, false),
5678            ("-lne", "print 3", false, true),
5679            ("-lnE", "p 4", false, true),
5680        ] {
5681            let a = expand_perl_bundled_argv(args(&["stryke", flag, code]));
5682            let cli = Cli::try_parse_from(&a).expect("parse bundled flags");
5683            assert!(
5684                cli.line_ending.is_some(),
5685                "{flag}: expected -l (line ending)"
5686            );
5687            assert_eq!(cli.auto_split, expect_a, "{flag}: autosplit (-a)");
5688            assert_eq!(cli.line_mode, expect_n, "{flag}: line loop (-n)");
5689            if flag.contains('E') {
5690                assert_eq!(cli.execute_features, vec![code]);
5691                assert!(cli.execute.is_empty());
5692            } else {
5693                assert_eq!(cli.execute, vec![code]);
5694                assert!(cli.execute_features.is_empty());
5695            }
5696        }
5697    }
5698
5699    #[test]
5700    fn bundled_lpe_preserves_print_mode() {
5701        let a = expand_perl_bundled_argv(args(&["stryke", "-lpe", "print 1"]));
5702        let cli = Cli::try_parse_from(&a).expect("parse");
5703        assert!(cli.print_mode);
5704        assert_eq!(cli.execute, vec!["print 1"]);
5705    }
5706
5707    #[test]
5708    fn bundled_0777_not_split() {
5709        let a = expand_perl_bundled_argv(args(&["stryke", "-0777", "-e", "1"]));
5710        assert!(
5711            a.contains(&"-0777".to_string()),
5712            "expected -0777 kept intact: {a:?}"
5713        );
5714    }
5715
5716    #[test]
5717    fn bundled_0ne_splits_like_perl() {
5718        let a = expand_perl_bundled_argv(args(&["stryke", "-0ne", "print 1"]));
5719        let cli = Cli::try_parse_from(&a).expect("parse");
5720        assert_eq!(cli.execute, vec!["print 1"]);
5721        assert!(cli.line_mode);
5722    }
5723
5724    #[test]
5725    fn bundled_f_colon_takes_rest_of_token() {
5726        let a = expand_perl_bundled_argv(args(&["stryke", "-F:", "-anE", "say $F[0]"]));
5727        let cli = Cli::try_parse_from(&a).expect("parse");
5728        assert_eq!(cli.field_separator.as_deref(), Some(":"));
5729        assert!(cli.auto_split);
5730        assert!(cli.line_mode);
5731        assert_eq!(cli.execute_features, vec!["say $F[0]"]);
5732    }
5733
5734    #[test]
5735    fn bundled_f_comma_takes_rest_of_token() {
5736        let a = expand_perl_bundled_argv(args(&["stryke", "-F,", "-anE", "print 1"]));
5737        let cli = Cli::try_parse_from(&a).expect("parse");
5738        assert_eq!(cli.field_separator.as_deref(), Some(","));
5739    }
5740
5741    #[test]
5742    fn help_alias_not_bundled_as_h_e_l_p() {
5743        let a = expand_perl_bundled_argv(args(&["stryke", "-help"]));
5744        let cli = Cli::try_parse_from(&a).expect("parse");
5745        assert!(cli.help);
5746    }
5747
5748    #[test]
5749    fn thread_operator_not_bundled() {
5750        // `->>` and `~>` are threading operators, not bundled flags
5751        let a = expand_perl_bundled_argv(args(&["stryke", "->> 1 p"]));
5752        assert_eq!(a, args(&["stryke", "->> 1 p"]));
5753
5754        let b = expand_perl_bundled_argv(args(&["stryke", "~> 1 p"]));
5755        assert_eq!(b, args(&["stryke", "~> 1 p"]));
5756    }
5757}