mod grammars;
use grammars::{
BUN, Cluster, DENO_RUN, DENO_SUBCOMMANDS, GO_RUN_VALUES, Grammar, LUA, NODE, OSASCRIPT, PERL,
PHP, PWSH, PYTHON, RSCRIPT, RUBY, TS_NODE, TSX,
};
#[derive(Debug, Default, PartialEq, Eq)]
pub(super) struct Program<'a> {
pub files: Vec<&'a str>,
pub module: Option<&'a str>,
pub stdin: bool,
}
pub(super) const SCRIPT_EXTENSIONS: &[&str] = &[
"py",
"pyw",
"js",
"mjs",
"cjs",
"ts",
"mts",
"cts",
"tsx",
"jsx",
"rb",
"pl",
"pm",
"php",
"sh",
"bash",
"zsh",
"ksh",
"fish",
"ps1",
"lua",
"r",
"go",
"scpt",
"applescript",
];
pub(super) fn program<'a>(program: &str, args: &'a [String]) -> Option<Program<'a>> {
let grammar = match base(program) {
"python" | "python3" | "python2" | "pypy" | "pypy3" => &PYTHON,
"perl" => &PERL,
"ruby" => &RUBY,
"node" | "nodejs" => &NODE,
"php" => &PHP,
"pwsh" | "powershell" => &PWSH,
"tsx" => return Some(scan(&TSX, skip(args, &["watch"]))),
"ts-node" | "ts-node-esm" | "ts-node-script" | "ts-node-transpile-only" => &TS_NODE,
"lua" | "luajit" => &LUA,
"Rscript" => &RSCRIPT,
"osascript" => &OSASCRIPT,
"deno" => return Some(deno(args)),
"bun" => return Some(scan(&BUN, skip(args, &["run"]))),
"go" => return Some(go_run(args)),
_ => return None,
};
Some(scan(grammar, args))
}
fn base(program: &str) -> &str {
let trimmed = program.trim_end_matches(|c: char| c.is_ascii_digit() || c == '.');
if trimmed.len() == program.len() {
return program;
}
match trimmed {
"python" => {
let version = &program[trimmed.len()..];
if version.starts_with('2') {
"python2"
} else {
"python3"
}
}
other => other,
}
}
fn skip<'a>(args: &'a [String], words: &[&str]) -> &'a [String] {
match args.first() {
Some(first) if words.contains(&first.as_str()) => &args[1..],
_ => args,
}
}
fn deno(args: &[String]) -> Program<'_> {
match args.first().map(String::as_str) {
Some("run" | "serve") => scan(&DENO_RUN, &args[1..]),
Some(first) if !first.starts_with('-') && !DENO_SUBCOMMANDS.contains(&first) => {
scan(&DENO_RUN, args)
}
_ => Program::default(),
}
}
fn go_run(args: &[String]) -> Program<'_> {
let mut program = Program::default();
let Some(("run", rest)) = args.split_first().map(|(f, r)| (f.as_str(), r)) else {
return program;
};
let mut i = 0;
while let Some(arg) = rest.get(i) {
let flag = arg
.strip_prefix('-')
.map(|f| format!("-{}", f.trim_start_matches('-')));
match flag {
Some(flag) if !flag.contains('=') && GO_RUN_VALUES.contains(&flag.as_str()) => i += 2,
Some(_) => i += 1,
None => break,
}
}
for arg in rest.get(i..).unwrap_or_default() {
let go = std::path::Path::new(arg)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("go"));
if !go {
break;
}
program.files.push(arg);
}
program
}
fn scan<'a>(grammar: &Grammar, args: &'a [String]) -> Program<'a> {
let mut program = Program::default();
let mut i = 0;
while let Some(arg) = args.get(i) {
i += 1;
let option = if grammar.any_case {
arg.to_ascii_lowercase()
} else {
arg.clone()
};
let named = |list: &[&str]| list.contains(&option.as_str());
if option == "--" {
program.files.extend(args.get(i).map(String::as_str));
return program;
}
if option == "-" {
program.stdin = true;
return program;
}
if !option.starts_with('-') {
if !grammar.file_like_only || file_like(arg) {
program.files.push(arg);
}
return program;
}
if named(grammar.file_options) {
program.files.extend(args.get(i).map(String::as_str));
return program;
}
if let Some((name, value)) = arg.split_once('=')
&& name.starts_with("--")
{
if grammar.preloads.contains(&name) {
program.files.push(value);
}
if grammar.no_file.contains(&name) {
return program;
}
continue;
}
if named(grammar.no_file) {
return program;
}
if named(grammar.preloads) {
program.files.extend(args.get(i).map(String::as_str));
i += 1;
continue;
}
if named(grammar.values) {
i += 1;
continue;
}
if let Some(cluster) = grammar.cluster
&& !arg.starts_with("--")
{
match letters(cluster, arg, args.get(i).map(String::as_str), &mut program) {
Letters::Options => {}
Letters::TookNext => i += 1,
Letters::NoFile => return program,
}
}
}
program.stdin = program.module.is_none();
program
}
enum Letters {
Options,
TookNext,
NoFile,
}
fn letters<'a>(
cluster: Cluster,
arg: &'a str,
next: Option<&'a str>,
program: &mut Program<'a>,
) -> Letters {
let body = &arg[1..];
for (at, letter) in body.char_indices() {
let rest = &body[at + letter.len_utf8()..];
let module = cluster.module == Some(letter);
if cluster.no_file.contains(letter) {
return Letters::NoFile;
}
if module || cluster.value.contains(letter) {
let (value, took) = if rest.is_empty() {
(next, true)
} else {
(Some(rest), false)
};
if module {
program.module = value;
return Letters::NoFile;
}
if cluster.preload.contains(letter) {
program.files.extend(value.filter(|v| file_like(v)));
}
return if took {
Letters::TookNext
} else {
Letters::Options
};
}
if cluster.glued.contains(letter) {
return Letters::Options;
}
}
Letters::Options
}
pub(super) fn file_like(word: &str) -> bool {
word.contains('/')
|| word
.rsplit_once('.')
.is_some_and(|(stem, ext)| !stem.is_empty() && has_script_extension(ext))
}
fn has_script_extension(ext: &str) -> bool {
SCRIPT_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str())
}