//! Host API functions registered into the Rhai engine. //! //! These functions provide safe, sandboxed helpers that plugin scripts can call. //! No filesystem access, no network, no process spawning — only pure string //! and data manipulation. use rhai::Engine; /// Register all host API functions into the engine. pub fn register(engine: &mut Engine) { // String helpers engine.register_fn("pad_left", pad_left); engine.register_fn("pad_right", pad_right); engine.register_fn("truncate", truncate); engine.register_fn("to_upper", to_upper); engine.register_fn("to_lower", to_lower); engine.register_fn("replace_char", replace_char); engine.register_fn("strip_non_ascii", strip_non_ascii); // Format helpers engine.register_fn("format_index", format_index); engine.register_fn("file_stem", file_stem); engine.register_fn("file_extension", file_extension); } /// Pad a string on the left to a given width with a fill character. /// Width is capped at 10,000 to prevent OOM from Rhai scripts. fn pad_left(s: &str, width: i64, fill: &str) -> String { let w = width.clamp(0, 10_000) as usize; let fill_ch = fill.chars().next().unwrap_or(' '); let len = s.chars().count(); if len >= w { return s.to_string(); } let padding: String = std::iter::repeat_n(fill_ch, w - len).collect(); format!("{padding}{s}") } /// Pad a string on the right to a given width with a fill character. /// Width is capped at 10,000 to prevent OOM from Rhai scripts. fn pad_right(s: &str, width: i64, fill: &str) -> String { let w = width.clamp(0, 10_000) as usize; let fill_ch = fill.chars().next().unwrap_or(' '); let len = s.chars().count(); if len >= w { return s.to_string(); } let padding: String = std::iter::repeat_n(fill_ch, w - len).collect(); format!("{s}{padding}") } /// Truncate a string to a maximum number of characters. fn truncate(s: &str, max_len: i64) -> String { let max = max_len.max(0) as usize; s.chars().take(max).collect() } /// Convert to uppercase. fn to_upper(s: &str) -> String { s.to_uppercase() } /// Convert to lowercase. fn to_lower(s: &str) -> String { s.to_lowercase() } /// Replace all occurrences of one character with another. fn replace_char(s: &str, from: &str, to: &str) -> String { let from_ch = from.chars().next().unwrap_or(' '); let to_ch = to.chars().next().unwrap_or(' '); s.chars() .map(|c| if c == from_ch { to_ch } else { c }) .collect() } /// Strip non-ASCII characters from a string. fn strip_non_ascii(s: &str) -> String { s.chars().filter(|c| c.is_ascii()).collect() } /// Format an index as a zero-padded string (e.g. index 3, width 3 → "003"). /// Width is capped at 10,000 to prevent OOM from Rhai scripts. fn format_index(index: i64, width: i64) -> String { let idx = index.max(0); let w = width.clamp(1, 10_000) as usize; format!("{idx:0>w$}") } /// Extract the stem (filename without extension) from a path string. fn file_stem(path: &str) -> String { std::path::Path::new(path) .file_stem() .and_then(|s| s.to_str()) .unwrap_or(path) .to_string() } /// Extract the extension from a path string (without the dot). fn file_extension(path: &str) -> String { std::path::Path::new(path) .extension() .and_then(|s| s.to_str()) .unwrap_or("") .to_string() } #[cfg(test)] mod tests { use super::*; #[test] fn pad_left_works() { assert_eq!(pad_left("42", 5, "0"), "00042"); assert_eq!(pad_left("hello", 3, " "), "hello"); } #[test] fn truncate_works() { assert_eq!(truncate("hello world", 5), "hello"); assert_eq!(truncate("hi", 10), "hi"); } #[test] fn format_index_works() { assert_eq!(format_index(3, 3), "003"); assert_eq!(format_index(42, 2), "42"); assert_eq!(format_index(1, 4), "0001"); } #[test] fn file_stem_and_extension() { assert_eq!(file_stem("kick.wav"), "kick"); assert_eq!(file_extension("kick.wav"), "wav"); assert_eq!(file_stem("noext"), "noext"); assert_eq!(file_extension("noext"), ""); } }