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