1use std::io::IsTerminal;
2use std::path::{Path, PathBuf};
3
4use anyhow::{Context, 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 pub json: bool,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum Command {
27 Execute(ExecutionSpec),
28 Repl {
29 initial_language: Option<LanguageSpec>,
30 detect_language: bool,
31 },
32 ShowVersion,
33 CheckToolchains,
34 ShowVersions {
35 language: Option<LanguageSpec>,
36 },
37 Install {
38 language: Option<LanguageSpec>,
39 package: String,
40 },
41 Bench {
42 spec: ExecutionSpec,
43 iterations: u32,
44 },
45 Watch {
46 spec: ExecutionSpec,
47 },
48 WatchFile {
49 path: PathBuf,
50 language: Option<LanguageSpec>,
51 args: Vec<String>,
52 },
53 Format {
54 path: PathBuf,
55 },
56 Snippet {
57 language: LanguageSpec,
58 name: Option<String>,
59 list: bool,
60 },
61 Doctor,
62 Cache {
63 action: CacheAction,
64 },
65 Share {
66 path: PathBuf,
67 port: Option<u16>,
68 },
69 PerfReport,
70 PerfReset,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub enum CacheAction {
75 Stats,
76 Clear,
77 ClearLang(String),
78}
79
80pub fn parse() -> Result<Command> {
81 let cli = Cli::parse();
82
83 if cli.version {
84 return Ok(Command::ShowVersion);
85 }
86 if cli.perf_report {
87 return Ok(Command::PerfReport);
88 }
89 if cli.perf_reset {
90 return Ok(Command::PerfReset);
91 }
92 if cli.check {
93 return Ok(Command::Doctor);
94 }
95 if cli.versions {
96 ensure!(
97 cli.code.is_none() && cli.file.is_none(),
98 "--versions does not accept --code or --file"
99 );
100 let mut language = cli
101 .lang
102 .as_ref()
103 .map(|value| LanguageSpec::new(value.to_string()));
104 let mut trailing = cli.args.clone();
105 if language.is_none()
106 && trailing.len() == 1
107 && crate::language::is_language_token(&trailing[0])
108 {
109 let raw = trailing.remove(0);
110 language = Some(LanguageSpec::new(raw));
111 }
112 ensure!(
113 trailing.is_empty(),
114 "Unexpected positional arguments after specifying --versions"
115 );
116 return Ok(Command::ShowVersions { language });
117 }
118
119 if let Some(pkg) = cli.install.as_ref() {
120 let language = cli
121 .lang
122 .as_ref()
123 .map(|value| LanguageSpec::new(value.to_string()));
124 return Ok(Command::Install {
125 language,
126 package: pkg.clone(),
127 });
128 }
129
130 crate::runtime::set_timeout(cli.timeout);
131
132 if cli.timing {
133 crate::runtime::enable_timing();
134 }
135
136 if let Some(code) = cli.code.as_ref() {
137 ensure!(
138 !code.trim().is_empty(),
139 "Inline code provided via --code must not be empty"
140 );
141 }
142
143 let mut trailing = cli.args.clone();
144 if let Some(command) = parse_subcommand(&mut trailing, cli.lang.as_deref())? {
145 return Ok(command);
146 }
147
148 let mut detect_language = !cli.no_detect;
149 let mut script_args: Vec<String> = Vec::new();
150
151 let mut language = cli
152 .lang
153 .as_ref()
154 .map(|value| LanguageSpec::new(value.to_string()));
155
156 if language.is_none()
157 && let Some(candidate) = trailing.first()
158 && crate::language::is_language_token(candidate)
159 {
160 let raw = trailing.remove(0);
161 language = Some(LanguageSpec::new(raw));
162 }
163
164 let mut source: Option<InputSource> = None;
165
166 if let Some(code) = cli.code {
167 ensure!(
168 cli.file.is_none(),
169 "--code/--inline cannot be combined with --file"
170 );
171 source = Some(InputSource::Inline(code));
172 script_args = trailing;
173 if script_args.first().map(|token| token.as_str()) == Some("--") {
174 script_args.remove(0);
175 }
176 trailing = Vec::new();
177 }
178
179 if source.is_none()
180 && let Some(path) = cli.file
181 {
182 source = Some(InputSource::File(path));
183 script_args = trailing;
184 if script_args.first().map(|token| token.as_str()) == Some("--") {
185 script_args.remove(0);
186 }
187 trailing = Vec::new();
188 }
189
190 if source.is_none() && !trailing.is_empty() {
191 match trailing.first().map(|token| token.as_str()) {
192 Some("-c") | Some("--code") => {
193 trailing.remove(0);
194 let (code_tokens, extra_args) = split_at_double_dash(&trailing);
195 ensure!(
196 !code_tokens.is_empty(),
197 "--code/--inline requires a code argument"
198 );
199 let joined = join_tokens(&code_tokens);
200 source = Some(InputSource::Inline(joined));
201 script_args = extra_args;
202 trailing.clear();
203 }
204 Some("-f") | Some("--file") => {
205 trailing.remove(0);
206 ensure!(!trailing.is_empty(), "--file requires a path argument");
207 let path = trailing.remove(0);
208 source = Some(InputSource::File(PathBuf::from(path)));
209 if trailing.first().map(|token| token.as_str()) == Some("--") {
210 trailing.remove(0);
211 }
212 script_args = trailing.clone();
213 trailing.clear();
214 }
215 _ => {}
216 }
217 }
218
219 if source.is_none() && !trailing.is_empty() {
220 let first = trailing.remove(0);
221 match first.as_str() {
222 "-" => {
223 source = Some(InputSource::Stdin);
224 if trailing.first().map(|token| token.as_str()) == Some("--") {
225 trailing.remove(0);
226 }
227 script_args = trailing.clone();
228 trailing.clear();
229 }
230 _ if looks_like_path(&first) => {
231 source = Some(InputSource::File(PathBuf::from(first)));
232 if trailing.first().map(|token| token.as_str()) == Some("--") {
233 trailing.remove(0);
234 }
235 script_args = trailing.clone();
236 trailing.clear();
237 }
238 _ => {
239 let mut all_tokens = Vec::with_capacity(trailing.len() + 1);
240 all_tokens.push(first);
241 all_tokens.append(&mut trailing);
242 let (code_tokens, extra_args) = split_at_double_dash(&all_tokens);
243 let joined = join_tokens(&code_tokens);
244 source = Some(InputSource::Inline(joined));
245 script_args = extra_args;
246 }
247 }
248 }
249
250 if source.is_none() && !cli.interactive {
251 let stdin = std::io::stdin();
252 if !stdin.is_terminal() {
253 source = Some(InputSource::Stdin);
254 }
255 }
256
257 if cli.interactive {
258 return Ok(Command::Repl {
259 initial_language: language,
260 detect_language,
261 });
262 }
263
264 if language.is_some() && !cli.no_detect {
265 detect_language = false;
266 }
267
268 if let Some(source) = source {
269 let spec = ExecutionSpec {
270 language,
271 source,
272 detect_language,
273 args: script_args,
274 json: cli.json,
275 };
276 if let Some(n) = cli.bench {
277 return Ok(Command::Bench {
278 spec,
279 iterations: n.max(1),
280 });
281 }
282 if cli.watch {
283 return Ok(Command::Watch { spec });
284 }
285 return Ok(Command::Execute(spec));
286 }
287
288 Ok(Command::Repl {
289 initial_language: language,
290 detect_language,
291 })
292}
293
294#[derive(Parser, Debug)]
295#[command(
296 name = "run",
297 about = "Universal multi-language runner and REPL",
298 long_about = "Universal multi-language runner and REPL. Run 2.0 is available via 'run v2' and is experimental.",
299 disable_help_subcommand = true,
300 disable_version_flag = true
301)]
302struct Cli {
303 #[arg(short = 'V', long = "version", action = clap::ArgAction::SetTrue)]
304 version: bool,
305
306 #[arg(
307 short,
308 long,
309 value_name = "LANG",
310 value_parser = NonEmptyStringValueParser::new()
311 )]
312 lang: Option<String>,
313
314 #[arg(
315 short,
316 long,
317 value_name = "PATH",
318 value_hint = ValueHint::FilePath
319 )]
320 file: Option<PathBuf>,
321
322 #[arg(
323 short = 'c',
324 long = "code",
325 value_name = "CODE",
326 value_parser = NonEmptyStringValueParser::new()
327 )]
328 code: Option<String>,
329
330 #[arg(long = "no-detect", action = clap::ArgAction::SetTrue)]
331 no_detect: bool,
332
333 #[arg(long = "timeout", value_name = "SECS")]
335 timeout: Option<u64>,
336
337 #[arg(long = "timing", action = clap::ArgAction::SetTrue)]
339 timing: bool,
340
341 #[arg(long = "json", action = clap::ArgAction::SetTrue)]
343 json: bool,
344
345 #[arg(long = "check", action = clap::ArgAction::SetTrue)]
347 check: bool,
348
349 #[arg(long = "versions", action = clap::ArgAction::SetTrue)]
351 versions: bool,
352
353 #[arg(long = "install", value_name = "PACKAGE")]
355 install: Option<String>,
356
357 #[arg(long = "bench", value_name = "N")]
359 bench: Option<u32>,
360
361 #[arg(short = 'w', long = "watch", action = clap::ArgAction::SetTrue)]
363 watch: bool,
364
365 #[arg(long = "perf-report", action = clap::ArgAction::SetTrue)]
367 perf_report: bool,
368
369 #[arg(long = "perf-reset", action = clap::ArgAction::SetTrue)]
371 perf_reset: bool,
372
373 #[arg(short = 'i', long = "interactive", action = clap::ArgAction::SetTrue)]
375 interactive: bool,
376
377 #[arg(value_name = "ARGS", trailing_var_arg = true)]
378 args: Vec<String>,
379}
380
381fn join_tokens(tokens: &[String]) -> String {
382 tokens.join(" ")
383}
384
385fn split_at_double_dash(tokens: &[String]) -> (Vec<String>, Vec<String>) {
386 if let Some(index) = tokens.iter().position(|token| token == "--") {
387 let before = tokens[..index].to_vec();
388 let after = tokens[index + 1..].to_vec();
389 (before, after)
390 } else {
391 (tokens.to_vec(), Vec::new())
392 }
393}
394
395fn parse_subcommand(args: &mut Vec<String>, lang: Option<&str>) -> Result<Option<Command>> {
396 let Some(first) = args.first().map(String::as_str) else {
397 return Ok(None);
398 };
399
400 match first {
401 "doctor" => {
402 args.remove(0);
403 ensure!(
404 args.is_empty(),
405 "doctor does not accept positional arguments"
406 );
407 Ok(Some(Command::Doctor))
408 }
409 "fmt" => {
410 args.remove(0);
411 ensure!(!args.is_empty(), "fmt requires a file path");
412 let path = PathBuf::from(args.remove(0));
413 ensure!(args.is_empty(), "fmt accepts exactly one file path");
414 Ok(Some(Command::Format { path }))
415 }
416 "snippet" => {
417 args.remove(0);
418 ensure!(!args.is_empty(), "snippet requires a language");
419 let language = LanguageSpec::new(args.remove(0));
420 let list = args
421 .first()
422 .is_some_and(|arg| arg == "--list" || arg == "-l");
423 let name = if list {
424 args.remove(0);
425 None
426 } else {
427 args.first().cloned()
428 };
429 if name.is_some() {
430 args.remove(0);
431 }
432 ensure!(
433 args.is_empty(),
434 "unexpected arguments after snippet command"
435 );
436 Ok(Some(Command::Snippet {
437 language,
438 name,
439 list,
440 }))
441 }
442 "cache" => {
443 args.remove(0);
444 let action = match args.first().map(String::as_str) {
445 None | Some("--stats") | Some("stats") => {
446 if !args.is_empty() {
447 args.remove(0);
448 }
449 CacheAction::Stats
450 }
451 Some("--clear") | Some("clear") => {
452 args.remove(0);
453 CacheAction::Clear
454 }
455 Some("--clear-lang") | Some("clear-lang") => {
456 args.remove(0);
457 ensure!(!args.is_empty(), "cache --clear-lang requires a language");
458 CacheAction::ClearLang(args.remove(0))
459 }
460 Some(other) => anyhow::bail!("unknown cache action '{other}'"),
461 };
462 ensure!(args.is_empty(), "unexpected arguments after cache command");
463 Ok(Some(Command::Cache { action }))
464 }
465 "watch" => {
466 args.remove(0);
467 ensure!(!args.is_empty(), "watch requires a file path");
468 let path = PathBuf::from(args.remove(0));
469 let mut rest = std::mem::take(args);
470 if rest.first().map(|token| token.as_str()) == Some("--") {
471 rest.remove(0);
472 }
473 Ok(Some(Command::WatchFile {
474 path,
475 language: lang.map(|value| LanguageSpec::new(value.to_string())),
476 args: rest,
477 }))
478 }
479 "share" => {
480 args.remove(0);
481 let mut port = None;
482 let mut path = None;
483 while let Some(arg) = args.first().cloned() {
484 args.remove(0);
485 if arg == "--port" {
486 ensure!(!args.is_empty(), "share --port requires a port");
487 let value = args.remove(0);
488 port = Some(value.parse::<u16>()?);
489 } else if path.is_none() {
490 path = Some(PathBuf::from(arg));
491 } else {
492 anyhow::bail!("share accepts exactly one file path");
493 }
494 }
495 let path = path.context("share requires a file path")?;
496 Ok(Some(Command::Share { path, port }))
497 }
498 _ => Ok(None),
499 }
500}
501
502fn looks_like_path(token: &str) -> bool {
503 if token == "-" {
504 return true;
505 }
506
507 if token.starts_with('-') || token.starts_with('"') || token.starts_with('\'') {
508 return false;
509 }
510
511 let path = Path::new(token);
512
513 if path.is_absolute() {
514 return true;
515 }
516
517 if token.starts_with("./") || token.starts_with("../") || token.starts_with("~/") {
518 return true;
519 }
520
521 if token.chars().any(|ch| ch.is_whitespace()) {
522 return false;
523 }
524
525 if let Some(ext) = path.extension().and_then(|ext| ext.to_str()) {
526 let ext_lower = ext.to_ascii_lowercase();
527 if KNOWN_CODE_EXTENSIONS
528 .iter()
529 .any(|candidate| candidate == &ext_lower.as_str())
530 {
531 return true;
532 }
533 }
534
535 if token.contains(std::path::MAIN_SEPARATOR) || token.contains('/') || token.contains('\\') {
536 return std::fs::metadata(path).is_ok();
537 }
538
539 false
540}
541
542const KNOWN_CODE_EXTENSIONS: &[&str] = &[
543 "py", "pyw", "rs", "rlib", "go", "js", "mjs", "cjs", "ts", "tsx", "jsx", "rb", "lua", "sh",
544 "bash", "zsh", "ps1", "php", "java", "kt", "swift", "scala", "clj", "fs", "cs", "c", "cc",
545 "cpp", "h", "hpp", "pl", "jl", "ex", "exs", "ml", "hs",
546];