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 if let Some(secs) = cli.timeout {
103 unsafe { std::env::set_var("RUN_TIMEOUT_SECS", secs.to_string()) };
105 }
106
107 if cli.timing {
109 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 #[arg(long = "timeout", value_name = "SECS")]
307 timeout: Option<u64>,
308
309 #[arg(long = "timing", action = clap::ArgAction::SetTrue)]
311 timing: bool,
312
313 #[arg(long = "check", action = clap::ArgAction::SetTrue)]
315 check: bool,
316
317 #[arg(long = "versions", action = clap::ArgAction::SetTrue)]
319 versions: bool,
320
321 #[arg(long = "install", value_name = "PACKAGE")]
323 install: Option<String>,
324
325 #[arg(long = "bench", value_name = "N")]
327 bench: Option<u32>,
328
329 #[arg(short = 'w', long = "watch", action = clap::ArgAction::SetTrue)]
331 watch: bool,
332
333 #[arg(long = "perf-report", action = clap::ArgAction::SetTrue)]
335 perf_report: bool,
336
337 #[arg(long = "perf-reset", action = clap::ArgAction::SetTrue)]
339 perf_reset: bool,
340
341 #[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];