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