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}
32
33pub fn parse() -> Result<Command> {
34 let cli = Cli::parse();
35
36 if cli.version {
37 return Ok(Command::ShowVersion);
38 }
39 if let Some(code) = cli.code.as_ref() {
40 ensure!(
41 !code.trim().is_empty(),
42 "Inline code provided via --code must not be empty"
43 );
44 }
45
46 let mut detect_language = !cli.no_detect;
47 let mut trailing = cli.args.clone();
48
49 let mut language = cli
50 .lang
51 .as_ref()
52 .map(|value| LanguageSpec::new(value.to_string()));
53
54 if language.is_none() {
55 if let Some(candidate) = trailing.first() {
56 if crate::language::is_language_token(candidate) {
57 let raw = trailing.remove(0);
58 language = Some(LanguageSpec::new(raw));
59 }
60 }
61 }
62
63 let mut source: Option<InputSource> = None;
64
65 if let Some(code) = cli.code {
66 ensure!(
67 cli.file.is_none(),
68 "--code/--inline cannot be combined with --file"
69 );
70 ensure!(
71 trailing.is_empty(),
72 "Unexpected positional arguments after specifying --code"
73 );
74 source = Some(InputSource::Inline(code));
75 }
76
77 if source.is_none() {
78 if let Some(path) = cli.file {
79 ensure!(
80 trailing.is_empty(),
81 "Unexpected positional arguments when --file is present"
82 );
83 source = Some(InputSource::File(path));
84 }
85 }
86
87 if source.is_none() && !trailing.is_empty() {
88 match trailing.first().map(|token| token.as_str()) {
89 Some("-c") | Some("--code") => {
90 trailing.remove(0);
91 ensure!(
92 !trailing.is_empty(),
93 "--code/--inline requires a code argument"
94 );
95 let joined = join_tokens(&trailing);
96 source = Some(InputSource::Inline(joined));
97 trailing.clear();
98 }
99 Some("-f") | Some("--file") => {
100 trailing.remove(0);
101 ensure!(!trailing.is_empty(), "--file requires a path argument");
102 ensure!(
103 trailing.len() == 1,
104 "Unexpected positional arguments after specifying --file"
105 );
106 let path = trailing.remove(0);
107 source = Some(InputSource::File(PathBuf::from(path)));
108 trailing.clear();
109 }
110 _ => {}
111 }
112 }
113
114 if source.is_none() && !trailing.is_empty() {
115 if trailing.len() == 1 {
116 let token = trailing.remove(0);
117 match token.as_str() {
118 "-" => {
119 source = Some(InputSource::Stdin);
120 }
121 _ if looks_like_path(&token) => {
122 source = Some(InputSource::File(PathBuf::from(token)));
123 }
124 _ => {
125 source = Some(InputSource::Inline(token));
126 }
127 }
128 } else {
129 let joined = join_tokens(&trailing);
130 source = Some(InputSource::Inline(joined));
131 }
132 }
133
134 if source.is_none() {
135 let stdin = std::io::stdin();
136 if !stdin.is_terminal() {
137 source = Some(InputSource::Stdin);
138 }
139 }
140
141 if language.is_some() && !cli.no_detect {
142 detect_language = false;
143 }
144
145 if let Some(source) = source {
146 return Ok(Command::Execute(ExecutionSpec {
147 language,
148 source,
149 detect_language,
150 }));
151 }
152
153 Ok(Command::Repl {
154 initial_language: language,
155 detect_language,
156 })
157}
158
159#[derive(Parser, Debug)]
160#[command(
161 name = "run",
162 about = "Universal multi-language runner and REPL",
163 long_about = "Universal multi-language runner and REPL. Run 2.0 is available via 'run v2' and is experimental.",
164 disable_help_subcommand = true,
165 disable_version_flag = true
166)]
167struct Cli {
168 #[arg(short = 'V', long = "version", action = clap::ArgAction::SetTrue)]
169 version: bool,
170
171 #[arg(
172 short,
173 long,
174 value_name = "LANG",
175 value_parser = NonEmptyStringValueParser::new()
176 )]
177 lang: Option<String>,
178
179 #[arg(
180 short,
181 long,
182 value_name = "PATH",
183 value_hint = ValueHint::FilePath
184 )]
185 file: Option<PathBuf>,
186
187 #[arg(
188 short = 'c',
189 long = "code",
190 value_name = "CODE",
191 value_parser = NonEmptyStringValueParser::new()
192 )]
193 code: Option<String>,
194
195 #[arg(long = "no-detect", action = clap::ArgAction::SetTrue)]
196 no_detect: bool,
197
198 #[arg(value_name = "ARGS", trailing_var_arg = true)]
199 args: Vec<String>,
200}
201
202fn join_tokens(tokens: &[String]) -> String {
203 tokens.join(" ")
204}
205
206fn looks_like_path(token: &str) -> bool {
207 if token == "-" {
208 return true;
209 }
210
211 let path = Path::new(token);
212
213 if path.is_absolute() {
214 return true;
215 }
216
217 if token.contains(std::path::MAIN_SEPARATOR) || token.contains('\\') {
218 return true;
219 }
220
221 if token.starts_with("./") || token.starts_with("../") || token.starts_with("~/") {
222 return true;
223 }
224
225 if std::fs::metadata(path).is_ok() {
226 return true;
227 }
228
229 if let Some(ext) = path.extension().and_then(|ext| ext.to_str()) {
230 let ext_lower = ext.to_ascii_lowercase();
231 if KNOWN_CODE_EXTENSIONS
232 .iter()
233 .any(|candidate| candidate == &ext_lower.as_str())
234 {
235 return true;
236 }
237 }
238
239 false
240}
241
242const KNOWN_CODE_EXTENSIONS: &[&str] = &[
243 "py", "pyw", "rs", "rlib", "go", "js", "mjs", "cjs", "ts", "tsx", "jsx", "rb", "lua", "sh",
244 "bash", "zsh", "ps1", "php", "java", "kt", "swift", "scala", "clj", "fs", "cs", "c", "cc",
245 "cpp", "h", "hpp", "pl", "jl", "ex", "exs", "ml", "hs",
246];