run/
cli.rs

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/// Represents the origin of code that should be executed.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum InputSource {
12    Inline(String),
13    File(PathBuf),
14    Stdin,
15}
16
17/// Encapsulates a single execution request derived from CLI input.
18#[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/// The top-level command requested by the user.
26#[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
36/// Parse CLI arguments into a high-level command.
37pub 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    /// Print version information and exit.
172    #[arg(short = 'V', long = "version", action = clap::ArgAction::SetTrue)]
173    version: bool,
174
175    /// Explicitly choose the language to execute.
176    #[arg(
177        short,
178        long,
179        value_name = "LANG",
180        value_parser = NonEmptyStringValueParser::new()
181    )]
182    lang: Option<String>,
183
184    /// Execute code from the provided file path.
185    #[arg(
186        short,
187        long,
188        value_name = "PATH",
189        value_hint = ValueHint::FilePath
190    )]
191    file: Option<PathBuf>,
192
193    /// Execute the provided code snippet.
194    #[arg(
195        short = 'c',
196        long = "code",
197        value_name = "CODE",
198        value_parser = NonEmptyStringValueParser::new()
199    )]
200    code: Option<String>,
201
202    /// Disable heuristic language detection.
203    #[arg(long = "no-detect", action = clap::ArgAction::SetTrue)]
204    no_detect: bool,
205
206    /// Positional arguments (language, code, or file).
207    #[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];