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