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}
22
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum Command {
25 Execute(ExecutionSpec),
26 Repl {
27 initial_language: Option<LanguageSpec>,
28 detect_language: bool,
29 },
30 ShowVersion,
31 CheckToolchains,
32 Install {
33 language: Option<LanguageSpec>,
34 package: String,
35 },
36 Bench {
37 spec: ExecutionSpec,
38 iterations: u32,
39 },
40 Watch {
41 spec: ExecutionSpec,
42 },
43}
44
45pub fn parse() -> Result<Command> {
46 let cli = Cli::parse();
47
48 if cli.version {
49 return Ok(Command::ShowVersion);
50 }
51 if cli.check {
52 return Ok(Command::CheckToolchains);
53 }
54
55 if let Some(pkg) = cli.install.as_ref() {
56 let language = cli
57 .lang
58 .as_ref()
59 .map(|value| LanguageSpec::new(value.to_string()));
60 return Ok(Command::Install {
61 language,
62 package: pkg.clone(),
63 });
64 }
65
66 if let Some(secs) = cli.timeout {
68 unsafe { std::env::set_var("RUN_TIMEOUT_SECS", secs.to_string()) };
70 }
71
72 if cli.timing {
74 unsafe { std::env::set_var("RUN_TIMING", "1") };
76 }
77
78 if let Some(code) = cli.code.as_ref() {
79 ensure!(
80 !code.trim().is_empty(),
81 "Inline code provided via --code must not be empty"
82 );
83 }
84
85 let mut detect_language = !cli.no_detect;
86 let mut trailing = cli.args.clone();
87
88 let mut language = cli
89 .lang
90 .as_ref()
91 .map(|value| LanguageSpec::new(value.to_string()));
92
93 if language.is_none()
94 && let Some(candidate) = trailing.first()
95 && crate::language::is_language_token(candidate)
96 {
97 let raw = trailing.remove(0);
98 language = Some(LanguageSpec::new(raw));
99 }
100
101 let mut source: Option<InputSource> = None;
102
103 if let Some(code) = cli.code {
104 ensure!(
105 cli.file.is_none(),
106 "--code/--inline cannot be combined with --file"
107 );
108 ensure!(
109 trailing.is_empty(),
110 "Unexpected positional arguments after specifying --code"
111 );
112 source = Some(InputSource::Inline(code));
113 }
114
115 if source.is_none()
116 && let Some(path) = cli.file
117 {
118 ensure!(
119 trailing.is_empty(),
120 "Unexpected positional arguments when --file is present"
121 );
122 source = Some(InputSource::File(path));
123 }
124
125 if source.is_none() && !trailing.is_empty() {
126 match trailing.first().map(|token| token.as_str()) {
127 Some("-c") | Some("--code") => {
128 trailing.remove(0);
129 ensure!(
130 !trailing.is_empty(),
131 "--code/--inline requires a code argument"
132 );
133 let joined = join_tokens(&trailing);
134 source = Some(InputSource::Inline(joined));
135 trailing.clear();
136 }
137 Some("-f") | Some("--file") => {
138 trailing.remove(0);
139 ensure!(!trailing.is_empty(), "--file requires a path argument");
140 ensure!(
141 trailing.len() == 1,
142 "Unexpected positional arguments after specifying --file"
143 );
144 let path = trailing.remove(0);
145 source = Some(InputSource::File(PathBuf::from(path)));
146 trailing.clear();
147 }
148 _ => {}
149 }
150 }
151
152 if source.is_none() && !trailing.is_empty() {
153 if trailing.len() == 1 {
154 let token = trailing.remove(0);
155 match token.as_str() {
156 "-" => {
157 source = Some(InputSource::Stdin);
158 }
159 _ if looks_like_path(&token) => {
160 source = Some(InputSource::File(PathBuf::from(token)));
161 }
162 _ => {
163 source = Some(InputSource::Inline(token));
164 }
165 }
166 } else {
167 let joined = join_tokens(&trailing);
168 source = Some(InputSource::Inline(joined));
169 }
170 }
171
172 if source.is_none() {
173 let stdin = std::io::stdin();
174 if !stdin.is_terminal() {
175 source = Some(InputSource::Stdin);
176 }
177 }
178
179 if language.is_some() && !cli.no_detect {
180 detect_language = false;
181 }
182
183 if let Some(source) = source {
184 let spec = ExecutionSpec {
185 language,
186 source,
187 detect_language,
188 };
189 if let Some(n) = cli.bench {
190 return Ok(Command::Bench {
191 spec,
192 iterations: n.max(1),
193 });
194 }
195 if cli.watch {
196 return Ok(Command::Watch { spec });
197 }
198 return Ok(Command::Execute(spec));
199 }
200
201 Ok(Command::Repl {
202 initial_language: language,
203 detect_language,
204 })
205}
206
207#[derive(Parser, Debug)]
208#[command(
209 name = "run",
210 about = "Universal multi-language runner and REPL",
211 long_about = "Universal multi-language runner and REPL. Run 2.0 is available via 'run v2' and is experimental.",
212 disable_help_subcommand = true,
213 disable_version_flag = true
214)]
215struct Cli {
216 #[arg(short = 'V', long = "version", action = clap::ArgAction::SetTrue)]
217 version: bool,
218
219 #[arg(
220 short,
221 long,
222 value_name = "LANG",
223 value_parser = NonEmptyStringValueParser::new()
224 )]
225 lang: Option<String>,
226
227 #[arg(
228 short,
229 long,
230 value_name = "PATH",
231 value_hint = ValueHint::FilePath
232 )]
233 file: Option<PathBuf>,
234
235 #[arg(
236 short = 'c',
237 long = "code",
238 value_name = "CODE",
239 value_parser = NonEmptyStringValueParser::new()
240 )]
241 code: Option<String>,
242
243 #[arg(long = "no-detect", action = clap::ArgAction::SetTrue)]
244 no_detect: bool,
245
246 #[arg(long = "timeout", value_name = "SECS")]
248 timeout: Option<u64>,
249
250 #[arg(long = "timing", action = clap::ArgAction::SetTrue)]
252 timing: bool,
253
254 #[arg(long = "check", action = clap::ArgAction::SetTrue)]
256 check: bool,
257
258 #[arg(long = "install", value_name = "PACKAGE")]
260 install: Option<String>,
261
262 #[arg(long = "bench", value_name = "N")]
264 bench: Option<u32>,
265
266 #[arg(short = 'w', long = "watch", action = clap::ArgAction::SetTrue)]
268 watch: bool,
269
270 #[arg(value_name = "ARGS", trailing_var_arg = true)]
271 args: Vec<String>,
272}
273
274fn join_tokens(tokens: &[String]) -> String {
275 tokens.join(" ")
276}
277
278fn looks_like_path(token: &str) -> bool {
279 if token == "-" {
280 return true;
281 }
282
283 let path = Path::new(token);
284
285 if path.is_absolute() {
286 return true;
287 }
288
289 if token.contains(std::path::MAIN_SEPARATOR) || token.contains('\\') {
290 return true;
291 }
292
293 if token.starts_with("./") || token.starts_with("../") || token.starts_with("~/") {
294 return true;
295 }
296
297 if std::fs::metadata(path).is_ok() {
298 return true;
299 }
300
301 if let Some(ext) = path.extension().and_then(|ext| ext.to_str()) {
302 let ext_lower = ext.to_ascii_lowercase();
303 if KNOWN_CODE_EXTENSIONS
304 .iter()
305 .any(|candidate| candidate == &ext_lower.as_str())
306 {
307 return true;
308 }
309 }
310
311 false
312}
313
314const KNOWN_CODE_EXTENSIONS: &[&str] = &[
315 "py", "pyw", "rs", "rlib", "go", "js", "mjs", "cjs", "ts", "tsx", "jsx", "rb", "lua", "sh",
316 "bash", "zsh", "ps1", "php", "java", "kt", "swift", "scala", "clj", "fs", "cs", "c", "cc",
317 "cpp", "h", "hpp", "pl", "jl", "ex", "exs", "ml", "hs",
318];