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