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