const BASES: &[&str] = &[
"environ",
"process.env",
"Deno.env",
"Bun.env",
"ENV",
"$ENV",
"%ENV",
"$_ENV",
"$_SERVER",
"getenv",
"$env:",
];
const WHOLE_METHODS: &[&str] = &[
"items",
"values",
"to_h",
"to_hash",
"to_a",
"each",
"each_pair",
"inspect",
"to_s",
"toObject",
"entries",
];
const PRINTERS: &[&str] = &["p", "pp", "puts", "print", "say", "echo"];
enum Access {
Name(String),
Whole,
Other,
}
pub(super) fn reads(code: &str) -> (Vec<String>, Option<&'static str>) {
let mut names: Vec<String> = Vec::new();
let mut whole = None;
for (at, base) in uses(code) {
let after = &code[at + base.len()..];
let all = match access(base, after) {
Access::Name(name) => {
if !names.contains(&name) {
names.push(name);
}
false
}
Access::Whole => true,
Access::Other => printed(code, at, base, after),
};
if all {
whole = whole.or(Some(base));
}
}
(names, whole)
}
fn is_ident(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '_'
}
fn uses(code: &str) -> Vec<(usize, &'static str)> {
let mut out = Vec::new();
for base in BASES {
for (at, _) in code.match_indices(base) {
let before = code[..at].chars().next_back();
let after = code[at + base.len()..].chars().next();
let open_before = match *base {
"environ" | "getenv" => before.is_none_or(|c| !is_ident(c)),
"ENV" => before.is_none_or(|c| !is_ident(c) && !matches!(c, '$' | '%' | '@' | '.')),
_ => before.is_none_or(|c| !is_ident(c) && c != '.'),
};
let open_after = base.ends_with(':') || after.is_none_or(|c| !is_ident(c));
if open_before && open_after {
out.push((at, *base));
}
}
}
out.sort_unstable();
out
}
fn literal(text: &str) -> Option<(String, &str)> {
let text = text.trim_start();
let quote = text.chars().next().filter(|c| matches!(c, '\'' | '"'))?;
let body = &text[1..];
let end = body.find(quote)?;
let name = &body[..end];
(!name.is_empty() && name.chars().all(is_ident)).then(|| (name.to_owned(), &body[end + 1..]))
}
fn argument(args: &str, empty_is_whole: bool) -> Access {
if let Some((name, _)) = literal(args) {
return Access::Name(name);
}
if args.trim_start().starts_with(')') && !empty_is_whole {
return Access::Other;
}
Access::Whole
}
fn access(base: &str, after: &str) -> Access {
match base {
"%ENV" => Access::Whole,
"getenv" => match after.trim_start().strip_prefix('(') {
Some(args) => argument(args, true),
None => Access::Other,
},
"$env:" => {
let name: String = after.chars().take_while(|c| is_ident(*c)).collect();
if name.is_empty() {
Access::Other
} else {
Access::Name(name)
}
}
"$ENV" => match after.strip_prefix('{') {
Some(body) => {
let inner = body.split('}').next().unwrap_or_default();
let key = inner.trim_matches(|c| matches!(c, '\'' | '"'));
if !key.is_empty() && key.chars().all(is_ident) {
Access::Name(key.to_owned())
} else {
Access::Whole
}
}
None => Access::Other,
},
_ => member(base, after),
}
}
fn member(base: &str, after: &str) -> Access {
if let Some(index) = after.strip_prefix('[') {
if let Some((name, rest)) = literal(index)
&& rest.trim_start().starts_with(']')
{
return Access::Name(name);
}
let assigned = index.split_once(']').is_some_and(|(_, rest)| {
let rest = rest.trim_start();
rest.starts_with('=') && !rest.starts_with("==")
});
return if assigned {
Access::Other
} else {
Access::Whole
};
}
let Some(field) = after.strip_prefix('.') else {
return Access::Other;
};
let method: String = field.chars().take_while(|c| is_ident(*c)).collect();
let rest = &field[method.len()..];
if WHOLE_METHODS.contains(&method.as_str()) {
return Access::Whole;
}
if let Some(args) = rest.trim_start().strip_prefix('(') {
return if matches!(method.as_str(), "get" | "fetch") {
argument(args, false)
} else {
Access::Other
};
}
if !method.is_empty() && base.contains(".env") {
return Access::Name(method);
}
Access::Other
}
fn printed(code: &str, at: usize, base: &str, after: &str) -> bool {
if base == "getenv" {
return false;
}
let start = code[..at]
.trim_end_matches(|c: char| is_ident(c) || c == '.')
.len();
let start = if base.starts_with(['$', '%']) {
at
} else {
start
};
let before = code[..start].trim_end();
let rest = after.trim_start();
if before.is_empty() && rest.trim_end_matches([';', '\n', ' ']).is_empty() {
return true;
}
if let Some(call) = before.strip_suffix('(')
&& rest.starts_with(')')
{
let name_start = call
.trim_end_matches(|c: char| is_ident(c) || c == '.')
.len();
let outer = code[..name_start].trim_end();
let handed_on = outer
.strip_suffix(['=', ':'])
.is_some_and(|o| o.trim_end().ends_with("env"));
return !handed_on;
}
let word_start = before.trim_end_matches(is_ident).len();
let word = &before[word_start..];
let spaced = start > before.len();
spaced && PRINTERS.contains(&word) && (rest.is_empty() || rest.starts_with([';', '\n', ')']))
}