Initial Renamr scaffold: Tauri (Rust core + React/TS UI), naming-expression engine ported+tested

This commit is contained in:
Renamr Scaffold
2026-08-29 01:43:12 +00:00
commit e01852f292
34 changed files with 7530 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
use rhai::Engine;
use std::sync::OnceLock;
use super::functions;
/// Shared Rhai engine, built once. Engines are relatively expensive to construct
/// (loading the standard package), so every [`crate::format::ExpressionFormat`]
/// compiles against this single shared instance.
pub(super) fn shared() -> &'static Engine {
static ENGINE: OnceLock<Engine> = OnceLock::new();
ENGINE.get_or_init(|| {
let mut engine = Engine::new();
functions::register(&mut engine);
engine
})
}
+171
View File
@@ -0,0 +1,171 @@
use rhai::{Dynamic, Scope, AST};
use std::collections::HashMap;
use thiserror::Error;
use super::engine;
/// The variables available inside a `{ }` expression, e.g. `n` -> show name.
pub type Bindings = HashMap<String, Dynamic>;
#[derive(Debug, Error)]
pub enum ExpressionError {
#[error("syntax error in expression `{expression}`: {message}")]
Syntax { expression: String, message: String },
#[error("expression yields empty value: {0}")]
Empty(String),
}
enum Segment {
Literal(String),
/// Fast path for bare identifiers like `{n}` or `{s00e00}`: skips the script engine
/// entirely, both for speed and so an undefined binding fails predictably.
Variable(String),
Script(AST),
}
/// A compiled naming-scheme expression, e.g. `"{n} - {s00e00} - {t}"`.
///
/// Compilation is separate from formatting (like `java.text.Format`/FileBot's
/// `ExpressionFormat`) so a pattern is parsed once and then applied to many files.
#[derive(Debug)]
pub struct ExpressionFormat {
expression: String,
segments: Vec<Segment>,
}
impl std::fmt::Debug for Segment {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Segment::Literal(s) => write!(f, "Literal({s:?})"),
Segment::Variable(s) => write!(f, "Variable({s:?})"),
Segment::Script(_) => write!(f, "Script(..)"),
}
}
}
impl ExpressionFormat {
pub fn compile(expression: &str) -> Result<Self, ExpressionError> {
let segments = parse(expression)?;
Ok(Self {
expression: expression.to_string(),
segments,
})
}
pub fn expression(&self) -> &str {
&self.expression
}
/// Renders this pattern against the given bindings. Mirrors FileBot's semantics:
/// a scriptlet that fails to evaluate (e.g. references an undefined/empty binding)
/// simply contributes nothing, but the *overall* result must be non-empty.
pub fn format(&self, bindings: &Bindings) -> Result<String, ExpressionError> {
let engine = engine::shared();
let mut out = String::new();
for segment in &self.segments {
match segment {
Segment::Literal(text) => out.push_str(text),
Segment::Variable(name) => {
if let Some(value) = bindings.get(name) {
if !value.is_unit() {
out.push_str(&value.to_string());
}
}
}
Segment::Script(ast) => {
let mut scope = Scope::new();
for (key, value) in bindings {
scope.push_dynamic(key.clone(), value.clone());
}
if let Ok(value) = engine.eval_ast_with_scope::<Dynamic>(&mut scope, ast) {
if !value.is_unit() {
out.push_str(&value.to_string());
}
}
}
}
}
if out.is_empty() {
return Err(ExpressionError::Empty(self.expression.clone()));
}
Ok(out)
}
}
fn is_identifier(token: &str) -> bool {
let mut chars = token.chars();
match chars.next() {
Some(c) if c.is_alphabetic() || c == '_' => {}
_ => return false,
}
chars.all(|c| c.is_alphanumeric() || c == '_')
}
fn compile_scriptlet(token: &str, expression: &str) -> Result<Segment, ExpressionError> {
if is_identifier(token) {
return Ok(Segment::Variable(token.to_string()));
}
engine::shared()
.compile(token)
.map(Segment::Script)
.map_err(|e| ExpressionError::Syntax {
expression: expression.to_string(),
message: e.to_string(),
})
}
/// Splits an expression into literal text and `{ }` scriptlets, honoring nested braces
/// (so a scriptlet may itself contain `{ }`, e.g. a map literal or block).
fn parse(expression: &str) -> Result<Vec<Segment>, ExpressionError> {
let mut segments = Vec::new();
let mut token = String::new();
let mut level = 0i32;
for c in expression.chars() {
match c {
'{' => {
if level == 0 {
if !token.is_empty() {
segments.push(Segment::Literal(std::mem::take(&mut token)));
}
} else {
token.push(c);
}
level += 1;
}
'}' => {
level -= 1;
if level < 0 {
return Err(ExpressionError::Syntax {
expression: expression.to_string(),
message: "unexpected '}'".to_string(),
});
}
if level == 0 {
if !token.is_empty() {
segments.push(compile_scriptlet(&std::mem::take(&mut token), expression)?);
}
} else {
token.push(c);
}
}
_ => token.push(c),
}
}
if level != 0 {
return Err(ExpressionError::Syntax {
expression: expression.to_string(),
message: "missing '}'".to_string(),
});
}
if !token.is_empty() {
segments.push(Segment::Literal(token));
}
Ok(segments)
}
@@ -0,0 +1,52 @@
//! Global helper functions available inside every `{ }` expression, e.g. `{any([x, y])}`.
//!
//! Starter subset ported from FileBot's `ExpressionFormatFunctions.java`. Rhai already
//! provides `to_upper`, `to_lower`, `trim`, `sub_string`, `len`, `contains`, `pad` and
//! friends as built-in string methods, so this module only adds the FileBot-specific
//! helpers that don't already have a Rhai equivalent. More helpers (csv/read_lines/
//! quote_bash/quote_powershell, etc.) are tracked in the porting backlog.
use rhai::{Array, Dynamic, Engine};
fn is_empty_value(value: &Dynamic) -> bool {
if value.is_unit() {
return true;
}
if let Some(s) = value.clone().try_cast::<rhai::ImmutableString>() {
return s.is_empty();
}
if let Some(arr) = value.clone().try_cast::<Array>() {
return arr.is_empty();
}
false
}
/// Returns the first array element that isn't empty/unit, or `()` if all are empty.
fn any_of(candidates: Array) -> Dynamic {
candidates
.into_iter()
.find(|v| !is_empty_value(v))
.unwrap_or(Dynamic::UNIT)
}
/// Returns all non-empty elements, preserving order.
fn all_of(candidates: Array) -> Array {
candidates.into_iter().filter(|v| !is_empty_value(v)).collect()
}
/// Concatenates all non-empty elements' string forms with no separator.
fn concat(parts: Array) -> String {
parts
.into_iter()
.filter(|v| !is_empty_value(v))
.map(|v| v.to_string())
.collect::<Vec<_>>()
.join("")
}
pub fn register(engine: &mut Engine) {
engine.register_fn("is_empty_value", is_empty_value);
engine.register_fn("any", any_of);
engine.register_fn("all_of", all_of);
engine.register_fn("concat", concat);
}
+18
View File
@@ -0,0 +1,18 @@
//! Renamr's naming-scheme expression language.
//!
//! A format string is literal text with `{ ... }` scriptlets spliced in, e.g.
//! `"{n} - {s00e00} - {t}"`
//! Everything inside `{ }` is evaluated as an expression against a set of "bindings"
//! (the variables available to the expression, e.g. `n` = show name).
//!
//! This keeps FileBot's outer `{ }` template syntax and simple-binding behavior
//! (`{n}`, `{s00e00}`, ...), so existing simple rename patterns keep working unchanged.
//! The inner expression *language* is Rhai rather than Groovy (Groovy is JVM-only and
//! this core is plain Rust) -- see the crate README for the compatibility notes on more
//! complex chained-method expressions.
mod engine;
mod expression;
mod functions;
pub use expression::{ExpressionError, ExpressionFormat};
+25
View File
@@ -0,0 +1,25 @@
//! Renamr core: the UI-agnostic engine behind the Renamr media renamer.
//!
//! This crate has no Tauri/UI dependency so it can be built and unit-tested standalone.
//! It's the piece being ported first from the original FileBot (Java) codebase; see
//! the workspace README for the porting roadmap (matching engine, media metadata
//! parsing, web datasource clients, rename engine are not yet ported).
pub mod format;
use rhai::Dynamic;
use std::collections::HashMap;
/// Convenience constructor for [`format::ExpressionFormat`] bindings from plain strings,
/// which is the common case (binding values are almost always show/movie/episode text).
pub fn string_bindings<I, K, V>(pairs: I) -> HashMap<String, Dynamic>
where
I: IntoIterator<Item = (K, V)>,
K: Into<String>,
V: Into<String>,
{
pairs
.into_iter()
.map(|(k, v)| (k.into(), Dynamic::from(v.into())))
.collect()
}