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 after_help = SUBCOMMAND_HELP,
300 disable_help_subcommand = true,
301 disable_version_flag = true
302)]
303struct Cli {
304 #[arg(short = 'V', long = "version", action = clap::ArgAction::SetTrue)]
305 version: bool,
306
307 #[arg(
308 short,
309 long,
310 value_name = "LANG",
311 value_parser = NonEmptyStringValueParser::new()
312 )]
313 lang: Option<String>,
314
315 #[arg(
316 short,
317 long,
318 value_name = "PATH",
319 value_hint = ValueHint::FilePath
320 )]
321 file: Option<PathBuf>,
322
323 #[arg(
324 short = 'c',
325 long = "code",
326 value_name = "CODE",
327 value_parser = NonEmptyStringValueParser::new()
328 )]
329 code: Option<String>,
330
331 #[arg(long = "no-detect", action = clap::ArgAction::SetTrue)]
332 no_detect: bool,
333
334 #[arg(long = "timeout", value_name = "SECS")]
336 timeout: Option<u64>,
337
338 #[arg(long = "timing", action = clap::ArgAction::SetTrue)]
340 timing: bool,
341
342 #[arg(long = "json", action = clap::ArgAction::SetTrue)]
344 json: bool,
345
346 #[arg(long = "check", action = clap::ArgAction::SetTrue)]
348 check: bool,
349
350 #[arg(long = "versions", action = clap::ArgAction::SetTrue)]
352 versions: bool,
353
354 #[arg(long = "install", value_name = "PACKAGE")]
356 install: Option<String>,
357
358 #[arg(long = "bench", value_name = "N")]
360 bench: Option<u32>,
361
362 #[arg(short = 'w', long = "watch", action = clap::ArgAction::SetTrue)]
364 watch: bool,
365
366 #[arg(long = "perf-report", action = clap::ArgAction::SetTrue)]
368 perf_report: bool,
369
370 #[arg(long = "perf-reset", action = clap::ArgAction::SetTrue)]
372 perf_reset: bool,
373
374 #[arg(short = 'i', long = "interactive", action = clap::ArgAction::SetTrue)]
376 interactive: bool,
377
378 #[arg(value_name = "ARGS", trailing_var_arg = true)]
379 args: Vec<String>,
380}
381
382fn join_tokens(tokens: &[String]) -> String {
383 tokens.join(" ")
384}
385
386fn split_at_double_dash(tokens: &[String]) -> (Vec<String>, Vec<String>) {
387 if let Some(index) = tokens.iter().position(|token| token == "--") {
388 let before = tokens[..index].to_vec();
389 let after = tokens[index + 1..].to_vec();
390 (before, after)
391 } else {
392 (tokens.to_vec(), Vec::new())
393 }
394}
395
396fn parse_subcommand(args: &mut Vec<String>, lang: Option<&str>) -> Result<Option<Command>> {
397 let Some(first) = args.first().map(String::as_str) else {
398 return Ok(None);
399 };
400
401 match first {
402 "doctor" => {
403 args.remove(0);
404 ensure!(
405 args.is_empty(),
406 "doctor does not accept positional arguments"
407 );
408 Ok(Some(Command::Doctor))
409 }
410 "fmt" => {
411 args.remove(0);
412 ensure!(!args.is_empty(), "fmt requires a file path");
413 let path = PathBuf::from(args.remove(0));
414 ensure!(args.is_empty(), "fmt accepts exactly one file path");
415 Ok(Some(Command::Format { path }))
416 }
417 "snippet" => {
418 args.remove(0);
419 ensure!(!args.is_empty(), "snippet requires a language");
420 let language = LanguageSpec::new(args.remove(0));
421 let list = args
422 .first()
423 .is_some_and(|arg| arg == "--list" || arg == "-l");
424 let name = if list {
425 args.remove(0);
426 None
427 } else {
428 args.first().cloned()
429 };
430 if name.is_some() {
431 args.remove(0);
432 }
433 ensure!(
434 args.is_empty(),
435 "unexpected arguments after snippet command"
436 );
437 Ok(Some(Command::Snippet {
438 language,
439 name,
440 list,
441 }))
442 }
443 "cache" => {
444 args.remove(0);
445 let action = match args.first().map(String::as_str) {
446 None | Some("--stats") | Some("stats") => {
447 if !args.is_empty() {
448 args.remove(0);
449 }
450 CacheAction::Stats
451 }
452 Some("--clear") | Some("clear") => {
453 args.remove(0);
454 CacheAction::Clear
455 }
456 Some("--clear-lang") | Some("clear-lang") => {
457 args.remove(0);
458 ensure!(!args.is_empty(), "cache --clear-lang requires a language");
459 CacheAction::ClearLang(args.remove(0))
460 }
461 Some(other) => anyhow::bail!("unknown cache action '{other}'"),
462 };
463 ensure!(args.is_empty(), "unexpected arguments after cache command");
464 Ok(Some(Command::Cache { action }))
465 }
466 "watch" => {
467 args.remove(0);
468 ensure!(!args.is_empty(), "watch requires a file path");
469 let path = PathBuf::from(args.remove(0));
470 let mut rest = std::mem::take(args);
471 if rest.first().map(|token| token.as_str()) == Some("--") {
472 rest.remove(0);
473 }
474 Ok(Some(Command::WatchFile {
475 path,
476 language: lang.map(|value| LanguageSpec::new(value.to_string())),
477 args: rest,
478 }))
479 }
480 "share" => {
481 args.remove(0);
482 let mut port = None;
483 let mut path = None;
484 while let Some(arg) = args.first().cloned() {
485 args.remove(0);
486 if arg == "--port" {
487 ensure!(!args.is_empty(), "share --port requires a port");
488 let value = args.remove(0);
489 port = Some(value.parse::<u16>()?);
490 } else if path.is_none() {
491 path = Some(PathBuf::from(arg));
492 } else {
493 anyhow::bail!("share accepts exactly one file path");
494 }
495 }
496 let path = path.context("share requires a file path")?;
497 Ok(Some(Command::Share { path, port }))
498 }
499 _ => Ok(None),
500 }
501}
502
503fn looks_like_path(token: &str) -> bool {
504 if token == "-" {
505 return true;
506 }
507
508 if token.starts_with('-') || token.starts_with('"') || token.starts_with('\'') {
509 return false;
510 }
511
512 let path = Path::new(token);
513
514 if path.is_absolute() {
515 return true;
516 }
517
518 if token.starts_with("./") || token.starts_with("../") || token.starts_with("~/") {
519 return true;
520 }
521
522 if token.chars().any(|ch| ch.is_whitespace()) {
523 return false;
524 }
525
526 if let Some(ext) = path.extension().and_then(|ext| ext.to_str()) {
527 let ext_lower = ext.to_ascii_lowercase();
528 if KNOWN_CODE_EXTENSIONS
529 .iter()
530 .any(|candidate| candidate == &ext_lower.as_str())
531 {
532 return true;
533 }
534 }
535
536 if token.contains(std::path::MAIN_SEPARATOR) || token.contains('/') || token.contains('\\') {
537 return std::fs::metadata(path).is_ok();
538 }
539
540 false
541}
542
543const KNOWN_CODE_EXTENSIONS: &[&str] = &[
544 "py", "pyw", "rs", "rlib", "go", "js", "mjs", "cjs", "ts", "tsx", "jsx", "rb", "lua", "sh",
545 "bash", "zsh", "ps1", "php", "java", "kt", "swift", "scala", "clj", "fs", "cs", "c", "cc",
546 "cpp", "h", "hpp", "pl", "jl", "ex", "exs", "ml", "hs",
547];
548
549const SUBCOMMAND_HELP: &str = "\
550Workflow commands:
551 run doctor Diagnose installed language toolchains
552 run cache --stats Show persistent build cache usage
553 run cache --clear Clear all persistent build cache entries
554 run cache --clear-lang L Clear cache entries for one language
555 run fmt <file> Format a file in place
556 run snippet <lang> <name> Print a curated offline snippet template
557 run snippet <lang> --list List templates for a language
558 run watch <file> Re-run a file when it changes
559 run share <file> [--port N] Serve a local highlighted file/output page
560 run v2 ... Use the experimental WASI component runtime";