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}
48
49pub fn parse() -> Result<Command> {
50 let cli = Cli::parse();
51
52 if cli.version {
53 return Ok(Command::ShowVersion);
54 }
55 if cli.check {
56 return Ok(Command::CheckToolchains);
57 }
58 if cli.versions {
59 ensure!(
60 cli.code.is_none() && cli.file.is_none(),
61 "--versions does not accept --code or --file"
62 );
63 let mut language = cli
64 .lang
65 .as_ref()
66 .map(|value| LanguageSpec::new(value.to_string()));
67 let mut trailing = cli.args.clone();
68 if language.is_none()
69 && trailing.len() == 1
70 && crate::language::is_language_token(&trailing[0])
71 {
72 let raw = trailing.remove(0);
73 language = Some(LanguageSpec::new(raw));
74 }
75 ensure!(
76 trailing.is_empty(),
77 "Unexpected positional arguments after specifying --versions"
78 );
79 return Ok(Command::ShowVersions { language });
80 }
81
82 if let Some(pkg) = cli.install.as_ref() {
83 let language = cli
84 .lang
85 .as_ref()
86 .map(|value| LanguageSpec::new(value.to_string()));
87 return Ok(Command::Install {
88 language,
89 package: pkg.clone(),
90 });
91 }
92
93 if let Some(secs) = cli.timeout {
95 unsafe { std::env::set_var("RUN_TIMEOUT_SECS", secs.to_string()) };
97 }
98
99 if cli.timing {
101 unsafe { std::env::set_var("RUN_TIMING", "1") };
103 }
104
105 if let Some(code) = cli.code.as_ref() {
106 ensure!(
107 !code.trim().is_empty(),
108 "Inline code provided via --code must not be empty"
109 );
110 }
111
112 let mut detect_language = !cli.no_detect;
113 let mut trailing = cli.args.clone();
114 let mut script_args: Vec<String> = Vec::new();
115
116 let mut language = cli
117 .lang
118 .as_ref()
119 .map(|value| LanguageSpec::new(value.to_string()));
120
121 if language.is_none()
122 && let Some(candidate) = trailing.first()
123 && crate::language::is_language_token(candidate)
124 {
125 let raw = trailing.remove(0);
126 language = Some(LanguageSpec::new(raw));
127 }
128
129 let mut source: Option<InputSource> = None;
130
131 if let Some(code) = cli.code {
132 ensure!(
133 cli.file.is_none(),
134 "--code/--inline cannot be combined with --file"
135 );
136 source = Some(InputSource::Inline(code));
137 script_args = trailing;
138 if script_args.first().map(|token| token.as_str()) == Some("--") {
139 script_args.remove(0);
140 }
141 trailing = Vec::new();
142 }
143
144 if source.is_none()
145 && let Some(path) = cli.file
146 {
147 source = Some(InputSource::File(path));
148 script_args = trailing;
149 if script_args.first().map(|token| token.as_str()) == Some("--") {
150 script_args.remove(0);
151 }
152 trailing = Vec::new();
153 }
154
155 if source.is_none() && !trailing.is_empty() {
156 match trailing.first().map(|token| token.as_str()) {
157 Some("-c") | Some("--code") => {
158 trailing.remove(0);
159 let (code_tokens, extra_args) = split_at_double_dash(&trailing);
160 ensure!(
161 !code_tokens.is_empty(),
162 "--code/--inline requires a code argument"
163 );
164 let joined = join_tokens(&code_tokens);
165 source = Some(InputSource::Inline(joined));
166 script_args = extra_args;
167 trailing.clear();
168 }
169 Some("-f") | Some("--file") => {
170 trailing.remove(0);
171 ensure!(!trailing.is_empty(), "--file requires a path argument");
172 let path = trailing.remove(0);
173 source = Some(InputSource::File(PathBuf::from(path)));
174 if trailing.first().map(|token| token.as_str()) == Some("--") {
175 trailing.remove(0);
176 }
177 script_args = trailing.clone();
178 trailing.clear();
179 }
180 _ => {}
181 }
182 }
183
184 if source.is_none() && !trailing.is_empty() {
185 let first = trailing.remove(0);
186 match first.as_str() {
187 "-" => {
188 source = Some(InputSource::Stdin);
189 if trailing.first().map(|token| token.as_str()) == Some("--") {
190 trailing.remove(0);
191 }
192 script_args = trailing.clone();
193 trailing.clear();
194 }
195 _ if looks_like_path(&first) => {
196 source = Some(InputSource::File(PathBuf::from(first)));
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 _ => {
204 let mut all_tokens = Vec::with_capacity(trailing.len() + 1);
205 all_tokens.push(first);
206 all_tokens.append(&mut trailing);
207 let (code_tokens, extra_args) = split_at_double_dash(&all_tokens);
208 let joined = join_tokens(&code_tokens);
209 source = Some(InputSource::Inline(joined));
210 script_args = extra_args;
211 }
212 }
213 }
214
215 if source.is_none() {
216 let stdin = std::io::stdin();
217 if !stdin.is_terminal() {
218 source = Some(InputSource::Stdin);
219 }
220 }
221
222 if language.is_some() && !cli.no_detect {
223 detect_language = false;
224 }
225
226 if let Some(source) = source {
227 let spec = ExecutionSpec {
228 language,
229 source,
230 detect_language,
231 args: script_args,
232 };
233 if let Some(n) = cli.bench {
234 return Ok(Command::Bench {
235 spec,
236 iterations: n.max(1),
237 });
238 }
239 if cli.watch {
240 return Ok(Command::Watch { spec });
241 }
242 return Ok(Command::Execute(spec));
243 }
244
245 Ok(Command::Repl {
246 initial_language: language,
247 detect_language,
248 })
249}
250
251#[derive(Parser, Debug)]
252#[command(
253 name = "run",
254 about = "Universal multi-language runner and REPL",
255 long_about = "Universal multi-language runner and REPL. Run 2.0 is available via 'run v2' and is experimental.",
256 disable_help_subcommand = true,
257 disable_version_flag = true
258)]
259struct Cli {
260 #[arg(short = 'V', long = "version", action = clap::ArgAction::SetTrue)]
261 version: bool,
262
263 #[arg(
264 short,
265 long,
266 value_name = "LANG",
267 value_parser = NonEmptyStringValueParser::new()
268 )]
269 lang: Option<String>,
270
271 #[arg(
272 short,
273 long,
274 value_name = "PATH",
275 value_hint = ValueHint::FilePath
276 )]
277 file: Option<PathBuf>,
278
279 #[arg(
280 short = 'c',
281 long = "code",
282 value_name = "CODE",
283 value_parser = NonEmptyStringValueParser::new()
284 )]
285 code: Option<String>,
286
287 #[arg(long = "no-detect", action = clap::ArgAction::SetTrue)]
288 no_detect: bool,
289
290 #[arg(long = "timeout", value_name = "SECS")]
292 timeout: Option<u64>,
293
294 #[arg(long = "timing", action = clap::ArgAction::SetTrue)]
296 timing: bool,
297
298 #[arg(long = "check", action = clap::ArgAction::SetTrue)]
300 check: bool,
301
302 #[arg(long = "versions", action = clap::ArgAction::SetTrue)]
304 versions: bool,
305
306 #[arg(long = "install", value_name = "PACKAGE")]
308 install: Option<String>,
309
310 #[arg(long = "bench", value_name = "N")]
312 bench: Option<u32>,
313
314 #[arg(short = 'w', long = "watch", action = clap::ArgAction::SetTrue)]
316 watch: bool,
317
318 #[arg(value_name = "ARGS", trailing_var_arg = true)]
319 args: Vec<String>,
320}
321
322fn join_tokens(tokens: &[String]) -> String {
323 tokens.join(" ")
324}
325
326fn split_at_double_dash(tokens: &[String]) -> (Vec<String>, Vec<String>) {
327 if let Some(index) = tokens.iter().position(|token| token == "--") {
328 let before = tokens[..index].to_vec();
329 let after = tokens[index + 1..].to_vec();
330 (before, after)
331 } else {
332 (tokens.to_vec(), Vec::new())
333 }
334}
335
336fn looks_like_path(token: &str) -> bool {
337 if token == "-" {
338 return true;
339 }
340
341 let path = Path::new(token);
342
343 if path.is_absolute() {
344 return true;
345 }
346
347 if token.starts_with("./") || token.starts_with("../") || token.starts_with("~/") {
348 return true;
349 }
350
351 if std::fs::metadata(path).is_ok() {
352 return true;
353 }
354
355 if token.chars().any(|ch| ch.is_whitespace()) {
356 return false;
357 }
358
359 if let Some(ext) = path.extension().and_then(|ext| ext.to_str()) {
360 let ext_lower = ext.to_ascii_lowercase();
361 if KNOWN_CODE_EXTENSIONS
362 .iter()
363 .any(|candidate| candidate == &ext_lower.as_str())
364 {
365 return true;
366 }
367 }
368
369 false
370}
371
372const KNOWN_CODE_EXTENSIONS: &[&str] = &[
373 "py", "pyw", "rs", "rlib", "go", "js", "mjs", "cjs", "ts", "tsx", "jsx", "rb", "lua", "sh",
374 "bash", "zsh", "ps1", "php", "java", "kt", "swift", "scala", "clj", "fs", "cs", "c", "cc",
375 "cpp", "h", "hpp", "pl", "jl", "ex", "exs", "ml", "hs",
376];