use crate::models::{Error, Result};
use std::collections::BTreeSet;
const HELPER_PREFIX: &str = "rash_";
fn defined_helpers(script: &str) -> BTreeSet<&str> {
script
.lines()
.filter_map(|line| {
let trimmed = line.trim_start();
let name = trimmed.strip_suffix("() {")?;
if name.starts_with(HELPER_PREFIX) && is_identifier(name) {
Some(name)
} else {
None
}
})
.collect()
}
fn is_identifier(candidate: &str) -> bool {
!candidate.is_empty()
&& candidate
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
fn called_helpers(script: &str) -> BTreeSet<&str> {
let mut found = BTreeSet::new();
for (start, _) in script.match_indices(HELPER_PREFIX) {
let rest = &script[start..];
let end = rest
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
.unwrap_or(rest.len());
let name = &rest[..end];
if name.len() <= HELPER_PREFIX.len() {
continue;
}
if rest[end..].starts_with('(') {
continue;
}
found.insert(name);
}
found
}
pub(crate) fn verify_calls_are_defined(script: &str) -> Result<()> {
let defined = defined_helpers(script);
let missing: Vec<&str> = called_helpers(script)
.into_iter()
.filter(|name| !defined.contains(name))
.collect();
if missing.is_empty() {
return Ok(());
}
let names = missing.join(", ");
Err(Error::Validation(format!(
"internal: the generated script calls {names}, which it does not define. \
The script would fail at runtime with `not found` (exit 127).\n\
\n\
This usually means a stdlib function was used in EXPRESSION position \
(`let v = mkdir(\"d\")`) when it only has a STATEMENT lowering \
(`mkdir(\"d\");`). Assigning the result of a void operation has no \
meaning in shell — call it as a statement.\n\
\n\
If the name is not a stdlib function, it is a call to something that \
does not exist; use exec() or capture() to run an external command.\n\
\n\
See bashrs#266."
)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn definition_is_not_a_call_to_itself() {
let script = "rash_println() {\n printf '%s\\n' \"$1\"\n}\n";
assert!(verify_calls_are_defined(script).is_ok());
}
#[test]
fn defined_and_called_passes() {
let script = "rash_println() {\n :\n}\nmain() {\n rash_println 'hi'\n}\n";
assert!(verify_calls_are_defined(script).is_ok());
}
#[test]
fn bashrs_266_undefined_helper_in_command_substitution_fails() {
let script = "main() {\n out=\"$(rash_exec 'echo hi')\"\n}\n";
let err = verify_calls_are_defined(script).unwrap_err();
assert!(format!("{err}").contains("rash_exec"), "{err}");
}
#[test]
fn reports_every_missing_helper_not_just_the_first() {
let script = "main() {\n a=\"$(rash_exec 'x')\"\n b=\"$(rash_mkdir 'd')\"\n}\n";
let err = format!("{}", verify_calls_are_defined(script).unwrap_err());
assert!(err.contains("rash_exec"), "{err}");
assert!(err.contains("rash_mkdir"), "{err}");
}
#[test]
fn indented_definition_still_counts() {
let script =
" rash_exec() {\n eval \"$1\"\n }\nmain() {\n rash_exec 'x'\n}\n";
assert!(verify_calls_are_defined(script).is_ok());
}
#[test]
fn bare_prefix_is_not_a_helper_name() {
let script = "main() {\n echo 'rash_'\n}\n";
assert!(verify_calls_are_defined(script).is_ok());
}
}