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