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