Skip to main content

run/
cli.rs

1use std::io::IsTerminal;
2use std::path::{Path, PathBuf};
3
4use anyhow::{Context, Result, ensure};
5use clap::{Parser, ValueHint, builder::NonEmptyStringValueParser};
6
7use crate::language::LanguageSpec;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum InputSource {
11    Inline(String),
12    File(PathBuf),
13    Stdin,
14}
15
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct ExecutionSpec {
18    pub language: Option<LanguageSpec>,
19    pub source: InputSource,
20    pub detect_language: bool,
21    pub args: Vec<String>,
22    pub json: bool,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum Command {
27    Execute(ExecutionSpec),
28    Repl {
29        initial_language: Option<LanguageSpec>,
30        detect_language: bool,
31    },
32    ShowVersion,
33    CheckToolchains,
34    ShowVersions {
35        language: Option<LanguageSpec>,
36    },
37    Install {
38        language: Option<LanguageSpec>,
39        package: String,
40    },
41    Bench {
42        spec: ExecutionSpec,
43        iterations: u32,
44    },
45    Watch {
46        spec: ExecutionSpec,
47    },
48    WatchFile {
49        path: PathBuf,
50        language: Option<LanguageSpec>,
51        args: Vec<String>,
52    },
53    Format {
54        path: PathBuf,
55    },
56    Snippet {
57        language: LanguageSpec,
58        name: Option<String>,
59        list: bool,
60    },
61    Doctor,
62    Cache {
63        action: CacheAction,
64    },
65    Share {
66        path: PathBuf,
67        port: Option<u16>,
68    },
69    PerfReport,
70    PerfReset,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub enum CacheAction {
75    Stats,
76    Clear,
77    ClearLang(String),
78}
79
80pub fn parse() -> Result<Command> {
81    let cli = Cli::parse();
82
83    if cli.version {
84        return Ok(Command::ShowVersion);
85    }
86    if cli.perf_report {
87        return Ok(Command::PerfReport);
88    }
89    if cli.perf_reset {
90        return Ok(Command::PerfReset);
91    }
92    if cli.check {
93        return Ok(Command::Doctor);
94    }
95    if cli.versions {
96        ensure!(
97            cli.code.is_none() && cli.file.is_none(),
98            "--versions does not accept --code or --file"
99        );
100        let mut language = cli
101            .lang
102            .as_ref()
103            .map(|value| LanguageSpec::new(value.to_string()));
104        let mut trailing = cli.args.clone();
105        if language.is_none()
106            && trailing.len() == 1
107            && crate::language::is_language_token(&trailing[0])
108        {
109            let raw = trailing.remove(0);
110            language = Some(LanguageSpec::new(raw));
111        }
112        ensure!(
113            trailing.is_empty(),
114            "Unexpected positional arguments after specifying --versions"
115        );
116        return Ok(Command::ShowVersions { language });
117    }
118
119    if let Some(pkg) = cli.install.as_ref() {
120        let language = cli
121            .lang
122            .as_ref()
123            .map(|value| LanguageSpec::new(value.to_string()));
124        return Ok(Command::Install {
125            language,
126            package: pkg.clone(),
127        });
128    }
129
130    crate::runtime::set_timeout(cli.timeout);
131
132    if cli.timing {
133        crate::runtime::enable_timing();
134    }
135
136    if let Some(code) = cli.code.as_ref() {
137        ensure!(
138            !code.trim().is_empty(),
139            "Inline code provided via --code must not be empty"
140        );
141    }
142
143    let mut trailing = cli.args.clone();
144    if let Some(command) = parse_subcommand(&mut trailing, cli.lang.as_deref())? {
145        return Ok(command);
146    }
147
148    let mut detect_language = !cli.no_detect;
149    let mut script_args: Vec<String> = Vec::new();
150
151    let mut language = cli
152        .lang
153        .as_ref()
154        .map(|value| LanguageSpec::new(value.to_string()));
155
156    if language.is_none()
157        && let Some(candidate) = trailing.first()
158        && crate::language::is_language_token(candidate)
159    {
160        let raw = trailing.remove(0);
161        language = Some(LanguageSpec::new(raw));
162    }
163
164    let mut source: Option<InputSource> = None;
165
166    if let Some(code) = cli.code {
167        ensure!(
168            cli.file.is_none(),
169            "--code/--inline cannot be combined with --file"
170        );
171        source = Some(InputSource::Inline(code));
172        script_args = trailing;
173        if script_args.first().map(|token| token.as_str()) == Some("--") {
174            script_args.remove(0);
175        }
176        trailing = Vec::new();
177    }
178
179    if source.is_none()
180        && let Some(path) = cli.file
181    {
182        source = Some(InputSource::File(path));
183        script_args = trailing;
184        if script_args.first().map(|token| token.as_str()) == Some("--") {
185            script_args.remove(0);
186        }
187        trailing = Vec::new();
188    }
189
190    if source.is_none() && !trailing.is_empty() {
191        match trailing.first().map(|token| token.as_str()) {
192            Some("-c") | Some("--code") => {
193                trailing.remove(0);
194                let (code_tokens, extra_args) = split_at_double_dash(&trailing);
195                ensure!(
196                    !code_tokens.is_empty(),
197                    "--code/--inline requires a code argument"
198                );
199                let joined = join_tokens(&code_tokens);
200                source = Some(InputSource::Inline(joined));
201                script_args = extra_args;
202                trailing.clear();
203            }
204            Some("-f") | Some("--file") => {
205                trailing.remove(0);
206                ensure!(!trailing.is_empty(), "--file requires a path argument");
207                let path = trailing.remove(0);
208                source = Some(InputSource::File(PathBuf::from(path)));
209                if trailing.first().map(|token| token.as_str()) == Some("--") {
210                    trailing.remove(0);
211                }
212                script_args = trailing.clone();
213                trailing.clear();
214            }
215            _ => {}
216        }
217    }
218
219    if source.is_none() && !trailing.is_empty() {
220        let first = trailing.remove(0);
221        match first.as_str() {
222            "-" => {
223                source = Some(InputSource::Stdin);
224                if trailing.first().map(|token| token.as_str()) == Some("--") {
225                    trailing.remove(0);
226                }
227                script_args = trailing.clone();
228                trailing.clear();
229            }
230            _ if looks_like_path(&first) => {
231                source = Some(InputSource::File(PathBuf::from(first)));
232                if trailing.first().map(|token| token.as_str()) == Some("--") {
233                    trailing.remove(0);
234                }
235                script_args = trailing.clone();
236                trailing.clear();
237            }
238            _ => {
239                let mut all_tokens = Vec::with_capacity(trailing.len() + 1);
240                all_tokens.push(first);
241                all_tokens.append(&mut trailing);
242                let (code_tokens, extra_args) = split_at_double_dash(&all_tokens);
243                let joined = join_tokens(&code_tokens);
244                source = Some(InputSource::Inline(joined));
245                script_args = extra_args;
246            }
247        }
248    }
249
250    if source.is_none() && !cli.interactive {
251        let stdin = std::io::stdin();
252        if !stdin.is_terminal() {
253            source = Some(InputSource::Stdin);
254        }
255    }
256
257    if cli.interactive {
258        return Ok(Command::Repl {
259            initial_language: language,
260            detect_language,
261        });
262    }
263
264    if language.is_some() && !cli.no_detect {
265        detect_language = false;
266    }
267
268    if let Some(source) = source {
269        let spec = ExecutionSpec {
270            language,
271            source,
272            detect_language,
273            args: script_args,
274            json: cli.json,
275        };
276        if let Some(n) = cli.bench {
277            return Ok(Command::Bench {
278                spec,
279                iterations: n.max(1),
280            });
281        }
282        if cli.watch {
283            return Ok(Command::Watch { spec });
284        }
285        return Ok(Command::Execute(spec));
286    }
287
288    Ok(Command::Repl {
289        initial_language: language,
290        detect_language,
291    })
292}
293
294#[derive(Parser, Debug)]
295#[command(
296    name = "run",
297    about = "Universal multi-language runner and REPL",
298    long_about = "Universal multi-language runner and REPL. Run 2.0 is available via 'run v2' and is experimental.",
299    after_help = SUBCOMMAND_HELP,
300    disable_help_subcommand = true,
301    disable_version_flag = true
302)]
303struct Cli {
304    #[arg(short = 'V', long = "version", action = clap::ArgAction::SetTrue)]
305    version: bool,
306
307    #[arg(
308        short,
309        long,
310        value_name = "LANG",
311        value_parser = NonEmptyStringValueParser::new()
312    )]
313    lang: Option<String>,
314
315    #[arg(
316        short,
317        long,
318        value_name = "PATH",
319        value_hint = ValueHint::FilePath
320    )]
321    file: Option<PathBuf>,
322
323    #[arg(
324        short = 'c',
325        long = "code",
326        value_name = "CODE",
327        value_parser = NonEmptyStringValueParser::new()
328    )]
329    code: Option<String>,
330
331    #[arg(long = "no-detect", action = clap::ArgAction::SetTrue)]
332    no_detect: bool,
333
334    /// Maximum execution time in seconds (0 = unlimited, override with RUN_TIMEOUT_SECS)
335    #[arg(long = "timeout", value_name = "SECS")]
336    timeout: Option<u64>,
337
338    /// Show execution timing after each run
339    #[arg(long = "timing", action = clap::ArgAction::SetTrue)]
340    timing: bool,
341
342    /// Emit a machine-readable JSON envelope for one-shot execution
343    #[arg(long = "json", action = clap::ArgAction::SetTrue)]
344    json: bool,
345
346    /// Check which language toolchains are available
347    #[arg(long = "check", action = clap::ArgAction::SetTrue)]
348    check: bool,
349
350    /// Show toolchain versions for available languages
351    #[arg(long = "versions", action = clap::ArgAction::SetTrue)]
352    versions: bool,
353
354    /// Install a package for a language (use -l to specify language, defaults to python)
355    #[arg(long = "install", value_name = "PACKAGE")]
356    install: Option<String>,
357
358    /// Benchmark: run code N times and report min/max/avg timing
359    #[arg(long = "bench", value_name = "N")]
360    bench: Option<u32>,
361
362    /// Watch a file and re-execute on changes
363    #[arg(short = 'w', long = "watch", action = clap::ArgAction::SetTrue)]
364    watch: bool,
365
366    /// Show in-memory performance counters collected in this process
367    #[arg(long = "perf-report", action = clap::ArgAction::SetTrue)]
368    perf_report: bool,
369
370    /// Reset in-memory performance counters in this process
371    #[arg(long = "perf-reset", action = clap::ArgAction::SetTrue)]
372    perf_reset: bool,
373
374    /// Force REPL (interactive) mode even when stdin is not a TTY (e.g. piped input)
375    #[arg(short = 'i', long = "interactive", action = clap::ArgAction::SetTrue)]
376    interactive: bool,
377
378    #[arg(value_name = "ARGS", trailing_var_arg = true)]
379    args: Vec<String>,
380}
381
382fn join_tokens(tokens: &[String]) -> String {
383    tokens.join(" ")
384}
385
386fn split_at_double_dash(tokens: &[String]) -> (Vec<String>, Vec<String>) {
387    if let Some(index) = tokens.iter().position(|token| token == "--") {
388        let before = tokens[..index].to_vec();
389        let after = tokens[index + 1..].to_vec();
390        (before, after)
391    } else {
392        (tokens.to_vec(), Vec::new())
393    }
394}
395
396fn parse_subcommand(args: &mut Vec<String>, lang: Option<&str>) -> Result<Option<Command>> {
397    let Some(first) = args.first().map(String::as_str) else {
398        return Ok(None);
399    };
400
401    match first {
402        "doctor" => {
403            args.remove(0);
404            ensure!(
405                args.is_empty(),
406                "doctor does not accept positional arguments"
407            );
408            Ok(Some(Command::Doctor))
409        }
410        "fmt" => {
411            args.remove(0);
412            ensure!(!args.is_empty(), "fmt requires a file path");
413            let path = PathBuf::from(args.remove(0));
414            ensure!(args.is_empty(), "fmt accepts exactly one file path");
415            Ok(Some(Command::Format { path }))
416        }
417        "snippet" => {
418            args.remove(0);
419            ensure!(!args.is_empty(), "snippet requires a language");
420            let language = LanguageSpec::new(args.remove(0));
421            let list = args
422                .first()
423                .is_some_and(|arg| arg == "--list" || arg == "-l");
424            let name = if list {
425                args.remove(0);
426                None
427            } else {
428                args.first().cloned()
429            };
430            if name.is_some() {
431                args.remove(0);
432            }
433            ensure!(
434                args.is_empty(),
435                "unexpected arguments after snippet command"
436            );
437            Ok(Some(Command::Snippet {
438                language,
439                name,
440                list,
441            }))
442        }
443        "cache" => {
444            args.remove(0);
445            let action = match args.first().map(String::as_str) {
446                None | Some("--stats") | Some("stats") => {
447                    if !args.is_empty() {
448                        args.remove(0);
449                    }
450                    CacheAction::Stats
451                }
452                Some("--clear") | Some("clear") => {
453                    args.remove(0);
454                    CacheAction::Clear
455                }
456                Some("--clear-lang") | Some("clear-lang") => {
457                    args.remove(0);
458                    ensure!(!args.is_empty(), "cache --clear-lang requires a language");
459                    CacheAction::ClearLang(args.remove(0))
460                }
461                Some(other) => anyhow::bail!("unknown cache action '{other}'"),
462            };
463            ensure!(args.is_empty(), "unexpected arguments after cache command");
464            Ok(Some(Command::Cache { action }))
465        }
466        "watch" => {
467            args.remove(0);
468            ensure!(!args.is_empty(), "watch requires a file path");
469            let path = PathBuf::from(args.remove(0));
470            let mut rest = std::mem::take(args);
471            if rest.first().map(|token| token.as_str()) == Some("--") {
472                rest.remove(0);
473            }
474            Ok(Some(Command::WatchFile {
475                path,
476                language: lang.map(|value| LanguageSpec::new(value.to_string())),
477                args: rest,
478            }))
479        }
480        "share" => {
481            args.remove(0);
482            let mut port = None;
483            let mut path = None;
484            while let Some(arg) = args.first().cloned() {
485                args.remove(0);
486                if arg == "--port" {
487                    ensure!(!args.is_empty(), "share --port requires a port");
488                    let value = args.remove(0);
489                    port = Some(value.parse::<u16>()?);
490                } else if path.is_none() {
491                    path = Some(PathBuf::from(arg));
492                } else {
493                    anyhow::bail!("share accepts exactly one file path");
494                }
495            }
496            let path = path.context("share requires a file path")?;
497            Ok(Some(Command::Share { path, port }))
498        }
499        _ => Ok(None),
500    }
501}
502
503fn looks_like_path(token: &str) -> bool {
504    if token == "-" {
505        return true;
506    }
507
508    if token.starts_with('-') || token.starts_with('"') || token.starts_with('\'') {
509        return false;
510    }
511
512    let path = Path::new(token);
513
514    if path.is_absolute() {
515        return true;
516    }
517
518    if token.starts_with("./") || token.starts_with("../") || token.starts_with("~/") {
519        return true;
520    }
521
522    if token.chars().any(|ch| ch.is_whitespace()) {
523        return false;
524    }
525
526    if let Some(ext) = path.extension().and_then(|ext| ext.to_str()) {
527        let ext_lower = ext.to_ascii_lowercase();
528        if KNOWN_CODE_EXTENSIONS
529            .iter()
530            .any(|candidate| candidate == &ext_lower.as_str())
531        {
532            return true;
533        }
534    }
535
536    if token.contains(std::path::MAIN_SEPARATOR) || token.contains('/') || token.contains('\\') {
537        return std::fs::metadata(path).is_ok();
538    }
539
540    false
541}
542
543const KNOWN_CODE_EXTENSIONS: &[&str] = &[
544    "py", "pyw", "rs", "rlib", "go", "js", "mjs", "cjs", "ts", "tsx", "jsx", "rb", "lua", "sh",
545    "bash", "zsh", "ps1", "php", "java", "kt", "swift", "scala", "clj", "fs", "cs", "c", "cc",
546    "cpp", "h", "hpp", "pl", "jl", "ex", "exs", "ml", "hs",
547];
548
549const SUBCOMMAND_HELP: &str = "\
550Workflow commands:
551  run doctor                 Diagnose installed language toolchains
552  run cache --stats          Show persistent build cache usage
553  run cache --clear          Clear all persistent build cache entries
554  run cache --clear-lang L   Clear cache entries for one language
555  run fmt <file>             Format a file in place
556  run snippet <lang> <name>  Print a curated offline snippet template
557  run snippet <lang> --list  List templates for a language
558  run watch <file>           Re-run a file when it changes
559  run share <file> [--port N] Serve a local highlighted file/output page
560  run v2 ...                 Use the experimental WASI component runtime";