Skip to main content

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#[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];