Initial Renamr scaffold: Tauri (Rust core + React/TS UI), naming-expression engine ported+tested
This commit is contained in:
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user