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    disable_help_subcommand = true,
300    disable_version_flag = true
301)]
302struct Cli {
303    #[arg(short = 'V', long = "version", action = clap::ArgAction::SetTrue)]
304    version: bool,
305
306    #[arg(
307        short,
308        long,
309        value_name = "LANG",
310        value_parser = NonEmptyStringValueParser::new()
311    )]
312    lang: Option<String>,
313
314    #[arg(
315        short,
316        long,
317        value_name = "PATH",
318        value_hint = ValueHint::FilePath
319    )]
320    file: Option<PathBuf>,
321
322    #[arg(
323        short = 'c',
324        long = "code",
325        value_name = "CODE",
326        value_parser = NonEmptyStringValueParser::new()
327    )]
328    code: Option<String>,
329
330    #[arg(long = "no-detect", action = clap::ArgAction::SetTrue)]
331    no_detect: bool,
332
333    /// Maximum execution time in seconds (0 = unlimited, override with RUN_TIMEOUT_SECS)
334    #[arg(long = "timeout", value_name = "SECS")]
335    timeout: Option<u64>,
336
337    /// Show execution timing after each run
338    #[arg(long = "timing", action = clap::ArgAction::SetTrue)]
339    timing: bool,
340
341    /// Emit a machine-readable JSON envelope for one-shot execution
342    #[arg(long = "json", action = clap::ArgAction::SetTrue)]
343    json: bool,
344
345    /// Check which language toolchains are available
346    #[arg(long = "check", action = clap::ArgAction::SetTrue)]
347    check: bool,
348
349    /// Show toolchain versions for available languages
350    #[arg(long = "versions", action = clap::ArgAction::SetTrue)]
351    versions: bool,
352
353    /// Install a package for a language (use -l to specify language, defaults to python)
354    #[arg(long = "install", value_name = "PACKAGE")]
355    install: Option<String>,
356
357    /// Benchmark: run code N times and report min/max/avg timing
358    #[arg(long = "bench", value_name = "N")]
359    bench: Option<u32>,
360
361    /// Watch a file and re-execute on changes
362    #[arg(short = 'w', long = "watch", action = clap::ArgAction::SetTrue)]
363    watch: bool,
364
365    /// Show in-memory performance counters collected in this process
366    #[arg(long = "perf-report", action = clap::ArgAction::SetTrue)]
367    perf_report: bool,
368
369    /// Reset in-memory performance counters in this process
370    #[arg(long = "perf-reset", action = clap::ArgAction::SetTrue)]
371    perf_reset: bool,
372
373    /// Force REPL (interactive) mode even when stdin is not a TTY (e.g. piped input)
374    #[arg(short = 'i', long = "interactive", action = clap::ArgAction::SetTrue)]
375    interactive: bool,
376
377    #[arg(value_name = "ARGS", trailing_var_arg = true)]
378    args: Vec<String>,
379}
380
381fn join_tokens(tokens: &[String]) -> String {
382    tokens.join(" ")
383}
384
385fn split_at_double_dash(tokens: &[String]) -> (Vec<String>, Vec<String>) {
386    if let Some(index) = tokens.iter().position(|token| token == "--") {
387        let before = tokens[..index].to_vec();
388        let after = tokens[index + 1..].to_vec();
389        (before, after)
390    } else {
391        (tokens.to_vec(), Vec::new())
392    }
393}
394
395fn parse_subcommand(args: &mut Vec<String>, lang: Option<&str>) -> Result<Option<Command>> {
396    let Some(first) = args.first().map(String::as_str) else {
397        return Ok(None);
398    };
399
400    match first {
401        "doctor" => {
402            args.remove(0);
403            ensure!(
404                args.is_empty(),
405                "doctor does not accept positional arguments"
406            );
407            Ok(Some(Command::Doctor))
408        }
409        "fmt" => {
410            args.remove(0);
411            ensure!(!args.is_empty(), "fmt requires a file path");
412            let path = PathBuf::from(args.remove(0));
413            ensure!(args.is_empty(), "fmt accepts exactly one file path");
414            Ok(Some(Command::Format { path }))
415        }
416        "snippet" => {
417            args.remove(0);
418            ensure!(!args.is_empty(), "snippet requires a language");
419            let language = LanguageSpec::new(args.remove(0));
420            let list = args
421                .first()
422                .is_some_and(|arg| arg == "--list" || arg == "-l");
423            let name = if list {
424                args.remove(0);
425                None
426            } else {
427                args.first().cloned()
428            };
429            if name.is_some() {
430                args.remove(0);
431            }
432            ensure!(
433                args.is_empty(),
434                "unexpected arguments after snippet command"
435            );
436            Ok(Some(Command::Snippet {
437                language,
438                name,
439                list,
440            }))
441        }
442        "cache" => {
443            args.remove(0);
444            let action = match args.first().map(String::as_str) {
445                None | Some("--stats") | Some("stats") => {
446                    if !args.is_empty() {
447                        args.remove(0);
448                    }
449                    CacheAction::Stats
450                }
451                Some("--clear") | Some("clear") => {
452                    args.remove(0);
453                    CacheAction::Clear
454                }
455                Some("--clear-lang") | Some("clear-lang") => {
456                    args.remove(0);
457                    ensure!(!args.is_empty(), "cache --clear-lang requires a language");
458                    CacheAction::ClearLang(args.remove(0))
459                }
460                Some(other) => anyhow::bail!("unknown cache action '{other}'"),
461            };
462            ensure!(args.is_empty(), "unexpected arguments after cache command");
463            Ok(Some(Command::Cache { action }))
464        }
465        "watch" => {
466            args.remove(0);
467            ensure!(!args.is_empty(), "watch requires a file path");
468            let path = PathBuf::from(args.remove(0));
469            let mut rest = std::mem::take(args);
470            if rest.first().map(|token| token.as_str()) == Some("--") {
471                rest.remove(0);
472            }
473            Ok(Some(Command::WatchFile {
474                path,
475                language: lang.map(|value| LanguageSpec::new(value.to_string())),
476                args: rest,
477            }))
478        }
479        "share" => {
480            args.remove(0);
481            let mut port = None;
482            let mut path = None;
483            while let Some(arg) = args.first().cloned() {
484                args.remove(0);
485                if arg == "--port" {
486                    ensure!(!args.is_empty(), "share --port requires a port");
487                    let value = args.remove(0);
488                    port = Some(value.parse::<u16>()?);
489                } else if path.is_none() {
490                    path = Some(PathBuf::from(arg));
491                } else {
492                    anyhow::bail!("share accepts exactly one file path");
493                }
494            }
495            let path = path.context("share requires a file path")?;
496            Ok(Some(Command::Share { path, port }))
497        }
498        _ => Ok(None),
499    }
500}
501
502fn looks_like_path(token: &str) -> bool {
503    if token == "-" {
504        return true;
505    }
506
507    if token.starts_with('-') || token.starts_with('"') || token.starts_with('\'') {
508        return false;
509    }
510
511    let path = Path::new(token);
512
513    if path.is_absolute() {
514        return true;
515    }
516
517    if token.starts_with("./") || token.starts_with("../") || token.starts_with("~/") {
518        return true;
519    }
520
521    if token.chars().any(|ch| ch.is_whitespace()) {
522        return false;
523    }
524
525    if let Some(ext) = path.extension().and_then(|ext| ext.to_str()) {
526        let ext_lower = ext.to_ascii_lowercase();
527        if KNOWN_CODE_EXTENSIONS
528            .iter()
529            .any(|candidate| candidate == &ext_lower.as_str())
530        {
531            return true;
532        }
533    }
534
535    if token.contains(std::path::MAIN_SEPARATOR) || token.contains('/') || token.contains('\\') {
536        return std::fs::metadata(path).is_ok();
537    }
538
539    false
540}
541
542const KNOWN_CODE_EXTENSIONS: &[&str] = &[
543    "py", "pyw", "rs", "rlib", "go", "js", "mjs", "cjs", "ts", "tsx", "jsx", "rb", "lua", "sh",
544    "bash", "zsh", "ps1", "php", "java", "kt", "swift", "scala", "clj", "fs", "cs", "c", "cc",
545    "cpp", "h", "hpp", "pl", "jl", "ex", "exs", "ml", "hs",
546];