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