Skip to main content

run/
cli.rs

1use std::io::IsTerminal;
2use std::path::{Path, PathBuf};
3
4use anyhow::{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}
23
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum Command {
26    Execute(ExecutionSpec),
27    Repl {
28        initial_language: Option<LanguageSpec>,
29        detect_language: bool,
30    },
31    ShowVersion,
32    CheckToolchains,
33    ShowVersions {
34        language: Option<LanguageSpec>,
35    },
36    Install {
37        language: Option<LanguageSpec>,
38        package: String,
39    },
40    Bench {
41        spec: ExecutionSpec,
42        iterations: u32,
43    },
44    Watch {
45        spec: ExecutionSpec,
46    },
47    PerfReport,
48    PerfReset,
49}
50
51pub fn parse() -> Result<Command> {
52    let cli = Cli::parse();
53
54    if cli.version {
55        return Ok(Command::ShowVersion);
56    }
57    if cli.perf_report {
58        return Ok(Command::PerfReport);
59    }
60    if cli.perf_reset {
61        return Ok(Command::PerfReset);
62    }
63    if cli.check {
64        return Ok(Command::CheckToolchains);
65    }
66    if cli.versions {
67        ensure!(
68            cli.code.is_none() && cli.file.is_none(),
69            "--versions does not accept --code or --file"
70        );
71        let mut language = cli
72            .lang
73            .as_ref()
74            .map(|value| LanguageSpec::new(value.to_string()));
75        let mut trailing = cli.args.clone();
76        if language.is_none()
77            && trailing.len() == 1
78            && crate::language::is_language_token(&trailing[0])
79        {
80            let raw = trailing.remove(0);
81            language = Some(LanguageSpec::new(raw));
82        }
83        ensure!(
84            trailing.is_empty(),
85            "Unexpected positional arguments after specifying --versions"
86        );
87        return Ok(Command::ShowVersions { language });
88    }
89
90    if let Some(pkg) = cli.install.as_ref() {
91        let language = cli
92            .lang
93            .as_ref()
94            .map(|value| LanguageSpec::new(value.to_string()));
95        return Ok(Command::Install {
96            language,
97            package: pkg.clone(),
98        });
99    }
100
101    // Apply --timeout if provided
102    if let Some(secs) = cli.timeout {
103        // SAFETY: called at startup before any threads are spawned
104        unsafe { std::env::set_var("RUN_TIMEOUT_SECS", secs.to_string()) };
105    }
106
107    // Apply --timing if provided
108    if cli.timing {
109        // SAFETY: called at startup before any threads are spawned
110        unsafe { std::env::set_var("RUN_TIMING", "1") };
111    }
112
113    if let Some(code) = cli.code.as_ref() {
114        ensure!(
115            !code.trim().is_empty(),
116            "Inline code provided via --code must not be empty"
117        );
118    }
119
120    let mut detect_language = !cli.no_detect;
121    let mut trailing = cli.args.clone();
122    let mut script_args: Vec<String> = Vec::new();
123
124    let mut language = cli
125        .lang
126        .as_ref()
127        .map(|value| LanguageSpec::new(value.to_string()));
128
129    if language.is_none()
130        && let Some(candidate) = trailing.first()
131        && crate::language::is_language_token(candidate)
132    {
133        let raw = trailing.remove(0);
134        language = Some(LanguageSpec::new(raw));
135    }
136
137    let mut source: Option<InputSource> = None;
138
139    if let Some(code) = cli.code {
140        ensure!(
141            cli.file.is_none(),
142            "--code/--inline cannot be combined with --file"
143        );
144        source = Some(InputSource::Inline(code));
145        script_args = trailing;
146        if script_args.first().map(|token| token.as_str()) == Some("--") {
147            script_args.remove(0);
148        }
149        trailing = Vec::new();
150    }
151
152    if source.is_none()
153        && let Some(path) = cli.file
154    {
155        source = Some(InputSource::File(path));
156        script_args = trailing;
157        if script_args.first().map(|token| token.as_str()) == Some("--") {
158            script_args.remove(0);
159        }
160        trailing = Vec::new();
161    }
162
163    if source.is_none() && !trailing.is_empty() {
164        match trailing.first().map(|token| token.as_str()) {
165            Some("-c") | Some("--code") => {
166                trailing.remove(0);
167                let (code_tokens, extra_args) = split_at_double_dash(&trailing);
168                ensure!(
169                    !code_tokens.is_empty(),
170                    "--code/--inline requires a code argument"
171                );
172                let joined = join_tokens(&code_tokens);
173                source = Some(InputSource::Inline(joined));
174                script_args = extra_args;
175                trailing.clear();
176            }
177            Some("-f") | Some("--file") => {
178                trailing.remove(0);
179                ensure!(!trailing.is_empty(), "--file requires a path argument");
180                let path = trailing.remove(0);
181                source = Some(InputSource::File(PathBuf::from(path)));
182                if trailing.first().map(|token| token.as_str()) == Some("--") {
183                    trailing.remove(0);
184                }
185                script_args = trailing.clone();
186                trailing.clear();
187            }
188            _ => {}
189        }
190    }
191
192    if source.is_none() && !trailing.is_empty() {
193        let first = trailing.remove(0);
194        match first.as_str() {
195            "-" => {
196                source = Some(InputSource::Stdin);
197                if trailing.first().map(|token| token.as_str()) == Some("--") {
198                    trailing.remove(0);
199                }
200                script_args = trailing.clone();
201                trailing.clear();
202            }
203            _ if looks_like_path(&first) => {
204                source = Some(InputSource::File(PathBuf::from(first)));
205                if trailing.first().map(|token| token.as_str()) == Some("--") {
206                    trailing.remove(0);
207                }
208                script_args = trailing.clone();
209                trailing.clear();
210            }
211            _ => {
212                let mut all_tokens = Vec::with_capacity(trailing.len() + 1);
213                all_tokens.push(first);
214                all_tokens.append(&mut trailing);
215                let (code_tokens, extra_args) = split_at_double_dash(&all_tokens);
216                let joined = join_tokens(&code_tokens);
217                source = Some(InputSource::Inline(joined));
218                script_args = extra_args;
219            }
220        }
221    }
222
223    if source.is_none() && !cli.interactive {
224        let stdin = std::io::stdin();
225        if !stdin.is_terminal() {
226            source = Some(InputSource::Stdin);
227        }
228    }
229
230    if cli.interactive {
231        return Ok(Command::Repl {
232            initial_language: language,
233            detect_language,
234        });
235    }
236
237    if language.is_some() && !cli.no_detect {
238        detect_language = false;
239    }
240
241    if let Some(source) = source {
242        let spec = ExecutionSpec {
243            language,
244            source,
245            detect_language,
246            args: script_args,
247        };
248        if let Some(n) = cli.bench {
249            return Ok(Command::Bench {
250                spec,
251                iterations: n.max(1),
252            });
253        }
254        if cli.watch {
255            return Ok(Command::Watch { spec });
256        }
257        return Ok(Command::Execute(spec));
258    }
259
260    Ok(Command::Repl {
261        initial_language: language,
262        detect_language,
263    })
264}
265
266#[derive(Parser, Debug)]
267#[command(
268    name = "run",
269    about = "Universal multi-language runner and REPL",
270    long_about = "Universal multi-language runner and REPL. Run 2.0 is available via 'run v2' and is experimental.",
271    disable_help_subcommand = true,
272    disable_version_flag = true
273)]
274struct Cli {
275    #[arg(short = 'V', long = "version", action = clap::ArgAction::SetTrue)]
276    version: bool,
277
278    #[arg(
279        short,
280        long,
281        value_name = "LANG",
282        value_parser = NonEmptyStringValueParser::new()
283    )]
284    lang: Option<String>,
285
286    #[arg(
287        short,
288        long,
289        value_name = "PATH",
290        value_hint = ValueHint::FilePath
291    )]
292    file: Option<PathBuf>,
293
294    #[arg(
295        short = 'c',
296        long = "code",
297        value_name = "CODE",
298        value_parser = NonEmptyStringValueParser::new()
299    )]
300    code: Option<String>,
301
302    #[arg(long = "no-detect", action = clap::ArgAction::SetTrue)]
303    no_detect: bool,
304
305    /// Maximum execution time in seconds (default: 60, override with RUN_TIMEOUT_SECS)
306    #[arg(long = "timeout", value_name = "SECS")]
307    timeout: Option<u64>,
308
309    /// Show execution timing after each run
310    #[arg(long = "timing", action = clap::ArgAction::SetTrue)]
311    timing: bool,
312
313    /// Check which language toolchains are available
314    #[arg(long = "check", action = clap::ArgAction::SetTrue)]
315    check: bool,
316
317    /// Show toolchain versions for available languages
318    #[arg(long = "versions", action = clap::ArgAction::SetTrue)]
319    versions: bool,
320
321    /// Install a package for a language (use -l to specify language, defaults to python)
322    #[arg(long = "install", value_name = "PACKAGE")]
323    install: Option<String>,
324
325    /// Benchmark: run code N times and report min/max/avg timing
326    #[arg(long = "bench", value_name = "N")]
327    bench: Option<u32>,
328
329    /// Watch a file and re-execute on changes
330    #[arg(short = 'w', long = "watch", action = clap::ArgAction::SetTrue)]
331    watch: bool,
332
333    /// Show in-memory performance counters collected in this process
334    #[arg(long = "perf-report", action = clap::ArgAction::SetTrue)]
335    perf_report: bool,
336
337    /// Reset in-memory performance counters in this process
338    #[arg(long = "perf-reset", action = clap::ArgAction::SetTrue)]
339    perf_reset: bool,
340
341    /// Force REPL (interactive) mode even when stdin is not a TTY (e.g. piped input)
342    #[arg(short = 'i', long = "interactive", action = clap::ArgAction::SetTrue)]
343    interactive: bool,
344
345    #[arg(value_name = "ARGS", trailing_var_arg = true)]
346    args: Vec<String>,
347}
348
349fn join_tokens(tokens: &[String]) -> String {
350    tokens.join(" ")
351}
352
353fn split_at_double_dash(tokens: &[String]) -> (Vec<String>, Vec<String>) {
354    if let Some(index) = tokens.iter().position(|token| token == "--") {
355        let before = tokens[..index].to_vec();
356        let after = tokens[index + 1..].to_vec();
357        (before, after)
358    } else {
359        (tokens.to_vec(), Vec::new())
360    }
361}
362
363fn looks_like_path(token: &str) -> bool {
364    if token == "-" {
365        return true;
366    }
367
368    let path = Path::new(token);
369
370    if path.is_absolute() {
371        return true;
372    }
373
374    if token.starts_with("./") || token.starts_with("../") || token.starts_with("~/") {
375        return true;
376    }
377
378    if std::fs::metadata(path).is_ok() {
379        return true;
380    }
381
382    if token.chars().any(|ch| ch.is_whitespace()) {
383        return false;
384    }
385
386    if let Some(ext) = path.extension().and_then(|ext| ext.to_str()) {
387        let ext_lower = ext.to_ascii_lowercase();
388        if KNOWN_CODE_EXTENSIONS
389            .iter()
390            .any(|candidate| candidate == &ext_lower.as_str())
391        {
392            return true;
393        }
394    }
395
396    false
397}
398
399const KNOWN_CODE_EXTENSIONS: &[&str] = &[
400    "py", "pyw", "rs", "rlib", "go", "js", "mjs", "cjs", "ts", "tsx", "jsx", "rb", "lua", "sh",
401    "bash", "zsh", "ps1", "php", "java", "kt", "swift", "scala", "clj", "fs", "cs", "c", "cc",
402    "cpp", "h", "hpp", "pl", "jl", "ex", "exs", "ml", "hs",
403];