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