Initial Renamr scaffold: Tauri (Rust core + React/TS UI), naming-expression engine ported+tested
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "renamr-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "Renamr core: naming-expression engine, media matching, and rename engine (UI-agnostic)."
|
||||
|
||||
[dependencies]
|
||||
# Embeddable scripting engine powering the {expression} naming-scheme language.
|
||||
# Stands in for the Groovy JSR-223 engine FileBot used (Groovy is JVM-only, not available
|
||||
# to a Rust core) while keeping the same outer `{ }` template syntax.
|
||||
rhai = { version = "1.19", features = ["sync"] }
|
||||
|
||||
thiserror = "1.0"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
use renamr_core::format::ExpressionFormat;
|
||||
use renamr_core::string_bindings;
|
||||
|
||||
#[test]
|
||||
fn simple_variable_substitution() {
|
||||
let fmt = ExpressionFormat::compile("{n} - {s00e00} - {t}").unwrap();
|
||||
let bindings = string_bindings([
|
||||
("n", "Breaking Bad"),
|
||||
("s00e00", "S05E14"),
|
||||
("t", "Ozymandias"),
|
||||
]);
|
||||
assert_eq!(fmt.format(&bindings).unwrap(), "Breaking Bad - S05E14 - Ozymandias");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scripted_expression_evaluation() {
|
||||
let fmt = ExpressionFormat::compile("{n.to_upper()}").unwrap();
|
||||
let bindings = string_bindings([("n", "Breaking Bad")]);
|
||||
assert_eq!(fmt.format(&bindings).unwrap(), "BREAKING BAD");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn literal_text_passes_through() {
|
||||
let fmt = ExpressionFormat::compile("plain text, no expressions").unwrap();
|
||||
assert_eq!(fmt.format(&Default::default()).unwrap(), "plain text, no expressions");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_helper_function_any() {
|
||||
let fmt = ExpressionFormat::compile("{any([x, y])}").unwrap();
|
||||
let bindings = string_bindings([("y", "fallback")]);
|
||||
// `x` isn't bound at all; referencing it inside the array literal would normally
|
||||
// error, so real usage is any([x_or_default, y]) - here we just prove `any` picks
|
||||
// the first non-empty value among ones that DO evaluate.
|
||||
let bindings2 = string_bindings([("x", ""), ("y", "fallback")]);
|
||||
let _ = bindings; // silence unused warning for the simplified case above
|
||||
assert_eq!(fmt.format(&bindings2).unwrap(), "fallback");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undefined_binding_alone_yields_error() {
|
||||
let fmt = ExpressionFormat::compile("{missing}").unwrap();
|
||||
let result = fmt.format(&Default::default());
|
||||
assert!(result.is_err(), "expected empty-result error, got {:?}", result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undefined_binding_is_skipped_when_other_content_exists() {
|
||||
let fmt = ExpressionFormat::compile("{n}{missing}").unwrap();
|
||||
let bindings = string_bindings([("n", "Show")]);
|
||||
assert_eq!(fmt.format(&bindings).unwrap(), "Show");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_braces_inside_a_scriptlet_are_preserved() {
|
||||
// Rhai object map literal: #{ key: value }
|
||||
let fmt = ExpressionFormat::compile("{ (#{a: 1, b: 2}).a }").unwrap();
|
||||
assert_eq!(fmt.format(&Default::default()).unwrap(), "1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn syntax_error_is_reported_with_the_offending_expression() {
|
||||
let err = ExpressionFormat::compile("{this is not valid rhai +++ }").unwrap_err();
|
||||
let message = err.to_string();
|
||||
assert!(message.contains("syntax error"), "message was: {message}");
|
||||
}
|
||||
Reference in New Issue
Block a user