- Add a new crate `sal-text` for text manipulation utilities. - Integrate `sal-text` into the main `sal` crate. - Remove the previous `text` module from `sal`. This improves organization and allows for independent development of the `sal-text` library.
This commit is contained in:
137
text/src/dedent.rs
Normal file
137
text/src/dedent.rs
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Dedent a multiline string by removing common leading whitespace.
|
||||
*
|
||||
* This function analyzes all non-empty lines in the input text to determine
|
||||
* the minimum indentation level, then removes that amount of whitespace
|
||||
* from the beginning of each line. This is useful for working with
|
||||
* multi-line strings in code that have been indented to match the
|
||||
* surrounding code structure.
|
||||
*
|
||||
* # Arguments
|
||||
*
|
||||
* * `text` - The multiline string to dedent
|
||||
*
|
||||
* # Returns
|
||||
*
|
||||
* * `String` - The dedented string
|
||||
*
|
||||
* # Examples
|
||||
*
|
||||
* ```
|
||||
* use sal::text::dedent;
|
||||
*
|
||||
* let indented = " line 1\n line 2\n line 3";
|
||||
* let dedented = dedent(indented);
|
||||
* assert_eq!(dedented, "line 1\nline 2\n line 3");
|
||||
* ```
|
||||
*
|
||||
* # Notes
|
||||
*
|
||||
* - Empty lines are preserved but have all leading whitespace removed
|
||||
* - Tabs are counted as 4 spaces for indentation purposes
|
||||
*/
|
||||
pub fn dedent(text: &str) -> String {
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
|
||||
// Find the minimum indentation level (ignore empty lines)
|
||||
let min_indent = lines
|
||||
.iter()
|
||||
.filter(|line| !line.trim().is_empty())
|
||||
.map(|line| {
|
||||
let mut spaces = 0;
|
||||
for c in line.chars() {
|
||||
if c == ' ' {
|
||||
spaces += 1;
|
||||
} else if c == '\t' {
|
||||
spaces += 4; // Count tabs as 4 spaces
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
spaces
|
||||
})
|
||||
.min()
|
||||
.unwrap_or(0);
|
||||
|
||||
// Remove that many spaces from the beginning of each line
|
||||
lines
|
||||
.iter()
|
||||
.map(|line| {
|
||||
if line.trim().is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let mut count = 0;
|
||||
let mut chars = line.chars().peekable();
|
||||
|
||||
// Skip initial spaces up to min_indent
|
||||
while count < min_indent && chars.peek().is_some() {
|
||||
match chars.peek() {
|
||||
Some(' ') => {
|
||||
chars.next();
|
||||
count += 1;
|
||||
}
|
||||
Some('\t') => {
|
||||
chars.next();
|
||||
count += 4;
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
|
||||
// Return the remaining characters
|
||||
chars.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<String>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefix a multiline string with a specified prefix.
|
||||
*
|
||||
* This function adds the specified prefix to the beginning of each line in the input text.
|
||||
*
|
||||
* # Arguments
|
||||
*
|
||||
* * `text` - The multiline string to prefix
|
||||
* * `prefix` - The prefix to add to each line
|
||||
*
|
||||
* # Returns
|
||||
*
|
||||
* * `String` - The prefixed string
|
||||
*
|
||||
* # Examples
|
||||
*
|
||||
* ```
|
||||
* use sal::text::prefix;
|
||||
*
|
||||
* let text = "line 1\nline 2\nline 3";
|
||||
* let prefixed = prefix(text, " ");
|
||||
* assert_eq!(prefixed, " line 1\n line 2\n line 3");
|
||||
* ```
|
||||
*/
|
||||
pub fn prefix(text: &str, prefix: &str) -> String {
|
||||
text.lines()
|
||||
.map(|line| format!("{}{}", prefix, line))
|
||||
.collect::<Vec<String>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_dedent() {
|
||||
let indented = " line 1\n line 2\n line 3";
|
||||
let dedented = dedent(indented);
|
||||
assert_eq!(dedented, "line 1\nline 2\n line 3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prefix() {
|
||||
let text = "line 1\nline 2\nline 3";
|
||||
let prefixed = prefix(text, " ");
|
||||
assert_eq!(prefixed, " line 1\n line 2\n line 3");
|
||||
}
|
||||
}
|
99
text/src/fix.rs
Normal file
99
text/src/fix.rs
Normal file
@@ -0,0 +1,99 @@
|
||||
|
||||
|
||||
pub fn name_fix(text: &str) -> String {
|
||||
let mut result = String::with_capacity(text.len());
|
||||
|
||||
let mut last_was_underscore = false;
|
||||
for c in text.chars() {
|
||||
// Keep only ASCII characters
|
||||
if c.is_ascii() {
|
||||
// Replace specific characters with underscore
|
||||
if c.is_whitespace() || c == ',' || c == '-' || c == '"' || c == '\'' ||
|
||||
c == '#' || c == '!' || c == '(' || c == ')' || c == '[' || c == ']' ||
|
||||
c == '=' || c == '+' || c == '<' || c == '>' || c == '@' || c == '$' ||
|
||||
c == '%' || c == '^' || c == '&' || c == '*' {
|
||||
// Only add underscore if the last character wasn't an underscore
|
||||
if !last_was_underscore {
|
||||
result.push('_');
|
||||
last_was_underscore = true;
|
||||
}
|
||||
} else {
|
||||
// Add the character as is (will be converted to lowercase later)
|
||||
result.push(c);
|
||||
last_was_underscore = false;
|
||||
}
|
||||
}
|
||||
// Non-ASCII characters are simply skipped
|
||||
}
|
||||
|
||||
// Convert to lowercase
|
||||
return result.to_lowercase();
|
||||
}
|
||||
|
||||
pub fn path_fix(text: &str) -> String {
|
||||
// If path ends with '/', return as is
|
||||
if text.ends_with('/') {
|
||||
return text.to_string();
|
||||
}
|
||||
|
||||
// Find the last '/' to extract the filename part
|
||||
match text.rfind('/') {
|
||||
Some(pos) => {
|
||||
// Extract the path and filename parts
|
||||
let path = &text[..=pos];
|
||||
let filename = &text[pos+1..];
|
||||
|
||||
// Apply name_fix to the filename part only
|
||||
return format!("{}{}", path, name_fix(filename));
|
||||
},
|
||||
None => {
|
||||
// No '/' found, so the entire text is a filename
|
||||
return name_fix(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_name_fix() {
|
||||
// Test ASCII conversion and special character replacement
|
||||
assert_eq!(name_fix("Hello World"), "hello_world");
|
||||
assert_eq!(name_fix("File-Name.txt"), "file_name.txt");
|
||||
assert_eq!(name_fix("Test!@#$%^&*()"), "test_");
|
||||
assert_eq!(name_fix("Space, Tab\t, Comma,"), "space_tab_comma_");
|
||||
assert_eq!(name_fix("Quotes\"'"), "quotes_");
|
||||
assert_eq!(name_fix("Brackets[]<>"), "brackets_");
|
||||
assert_eq!(name_fix("Operators=+-"), "operators_");
|
||||
|
||||
// Test non-ASCII characters removal
|
||||
assert_eq!(name_fix("Café"), "caf");
|
||||
assert_eq!(name_fix("Résumé"), "rsum");
|
||||
assert_eq!(name_fix("Über"), "ber");
|
||||
|
||||
// Test lowercase conversion
|
||||
assert_eq!(name_fix("UPPERCASE"), "uppercase");
|
||||
assert_eq!(name_fix("MixedCase"), "mixedcase");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_path_fix() {
|
||||
// Test path ending with /
|
||||
assert_eq!(path_fix("/path/to/directory/"), "/path/to/directory/");
|
||||
|
||||
// Test single filename
|
||||
assert_eq!(path_fix("filename.txt"), "filename.txt");
|
||||
assert_eq!(path_fix("UPPER-file.md"), "upper_file.md");
|
||||
|
||||
// Test path with filename
|
||||
assert_eq!(path_fix("/path/to/File Name.txt"), "/path/to/file_name.txt");
|
||||
assert_eq!(path_fix("./relative/path/to/DOCUMENT-123.pdf"), "./relative/path/to/document_123.pdf");
|
||||
assert_eq!(path_fix("/absolute/path/to/Résumé.doc"), "/absolute/path/to/rsum.doc");
|
||||
|
||||
// Test path with special characters in filename
|
||||
assert_eq!(path_fix("/path/with/[special]<chars>.txt"), "/path/with/_special_chars_.txt");
|
||||
}
|
||||
}
|
||||
|
59
text/src/lib.rs
Normal file
59
text/src/lib.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
//! SAL Text - Text processing and manipulation utilities
|
||||
//!
|
||||
//! This crate provides a comprehensive collection of text processing utilities including:
|
||||
//! - **Text indentation**: Remove common leading whitespace (`dedent`) and add prefixes (`prefix`)
|
||||
//! - **String normalization**: Sanitize strings for filenames (`name_fix`) and paths (`path_fix`)
|
||||
//! - **Text replacement**: Powerful `TextReplacer` for regex and literal replacements
|
||||
//! - **Template rendering**: `TemplateBuilder` using Tera engine for dynamic text generation
|
||||
//!
|
||||
//! All functionality is available in both Rust and Rhai scripting environments.
|
||||
//!
|
||||
//! # Examples
|
||||
//!
|
||||
//! ## Text Indentation
|
||||
//!
|
||||
//! ```rust
|
||||
//! use sal_text::dedent;
|
||||
//!
|
||||
//! let indented = " line 1\n line 2\n line 3";
|
||||
//! let dedented = dedent(indented);
|
||||
//! assert_eq!(dedented, "line 1\nline 2\n line 3");
|
||||
//! ```
|
||||
//!
|
||||
//! ## String Normalization
|
||||
//!
|
||||
//! ```rust
|
||||
//! use sal_text::name_fix;
|
||||
//!
|
||||
//! let unsafe_name = "User's File [Draft].txt";
|
||||
//! let safe_name = name_fix(unsafe_name);
|
||||
//! assert_eq!(safe_name, "users_file_draft_.txt");
|
||||
//! ```
|
||||
//!
|
||||
//! ## Text Replacement
|
||||
//!
|
||||
//! ```rust
|
||||
//! use sal_text::TextReplacer;
|
||||
//!
|
||||
//! let replacer = TextReplacer::builder()
|
||||
//! .pattern(r"\d+")
|
||||
//! .replacement("NUMBER")
|
||||
//! .regex(true)
|
||||
//! .build()
|
||||
//! .expect("Failed to build replacer");
|
||||
//!
|
||||
//! let result = replacer.replace("There are 123 items");
|
||||
//! assert_eq!(result, "There are NUMBER items");
|
||||
//! ```
|
||||
|
||||
mod dedent;
|
||||
mod fix;
|
||||
mod replace;
|
||||
mod template;
|
||||
|
||||
pub mod rhai;
|
||||
|
||||
pub use dedent::*;
|
||||
pub use fix::*;
|
||||
pub use replace::*;
|
||||
pub use template::*;
|
292
text/src/replace.rs
Normal file
292
text/src/replace.rs
Normal file
@@ -0,0 +1,292 @@
|
||||
use regex::Regex;
|
||||
use std::fs;
|
||||
use std::io::{self, Read};
|
||||
use std::path::Path;
|
||||
|
||||
/// Represents the type of replacement to perform.
|
||||
#[derive(Clone)]
|
||||
pub enum ReplaceMode {
|
||||
/// Regex-based replacement using the `regex` crate
|
||||
Regex(Regex),
|
||||
/// Literal substring replacement (non-regex)
|
||||
Literal(String),
|
||||
}
|
||||
|
||||
/// A single replacement operation with a pattern and replacement text
|
||||
#[derive(Clone)]
|
||||
pub struct ReplacementOperation {
|
||||
mode: ReplaceMode,
|
||||
replacement: String,
|
||||
}
|
||||
|
||||
impl ReplacementOperation {
|
||||
/// Applies this replacement operation to the input text
|
||||
fn apply(&self, input: &str) -> String {
|
||||
match &self.mode {
|
||||
ReplaceMode::Regex(re) => re.replace_all(input, self.replacement.as_str()).to_string(),
|
||||
ReplaceMode::Literal(search) => input.replace(search, &self.replacement),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Text replacer that can perform multiple replacement operations
|
||||
/// in a single pass over the input text.
|
||||
#[derive(Clone)]
|
||||
pub struct TextReplacer {
|
||||
operations: Vec<ReplacementOperation>,
|
||||
}
|
||||
|
||||
impl TextReplacer {
|
||||
/// Creates a new builder for configuring a TextReplacer
|
||||
pub fn builder() -> TextReplacerBuilder {
|
||||
TextReplacerBuilder::default()
|
||||
}
|
||||
|
||||
/// Applies all configured replacement operations to the input text
|
||||
pub fn replace(&self, input: &str) -> String {
|
||||
let mut result = input.to_string();
|
||||
|
||||
// Apply each replacement operation in sequence
|
||||
for op in &self.operations {
|
||||
result = op.apply(&result);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Reads a file, applies all replacements, and returns the result as a string
|
||||
pub fn replace_file<P: AsRef<Path>>(&self, path: P) -> io::Result<String> {
|
||||
let mut file = fs::File::open(path)?;
|
||||
let mut content = String::new();
|
||||
file.read_to_string(&mut content)?;
|
||||
|
||||
Ok(self.replace(&content))
|
||||
}
|
||||
|
||||
/// Reads a file, applies all replacements, and writes the result back to the file
|
||||
pub fn replace_file_in_place<P: AsRef<Path>>(&self, path: P) -> io::Result<()> {
|
||||
let content = self.replace_file(&path)?;
|
||||
fs::write(path, content)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reads a file, applies all replacements, and writes the result to a new file
|
||||
pub fn replace_file_to<P1: AsRef<Path>, P2: AsRef<Path>>(
|
||||
&self,
|
||||
input_path: P1,
|
||||
output_path: P2,
|
||||
) -> io::Result<()> {
|
||||
let content = self.replace_file(&input_path)?;
|
||||
fs::write(output_path, content)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder for the TextReplacer.
|
||||
#[derive(Default, Clone)]
|
||||
pub struct TextReplacerBuilder {
|
||||
operations: Vec<ReplacementOperation>,
|
||||
pattern: Option<String>,
|
||||
replacement: Option<String>,
|
||||
use_regex: bool,
|
||||
case_insensitive: bool,
|
||||
}
|
||||
|
||||
impl TextReplacerBuilder {
|
||||
/// Sets the pattern to search for
|
||||
pub fn pattern(mut self, pat: &str) -> Self {
|
||||
self.pattern = Some(pat.to_string());
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the replacement text
|
||||
pub fn replacement(mut self, rep: &str) -> Self {
|
||||
self.replacement = Some(rep.to_string());
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets whether to use regex
|
||||
pub fn regex(mut self, yes: bool) -> Self {
|
||||
self.use_regex = yes;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets whether the replacement should be case-insensitive
|
||||
pub fn case_insensitive(mut self, yes: bool) -> Self {
|
||||
self.case_insensitive = yes;
|
||||
self
|
||||
}
|
||||
|
||||
/// Adds another replacement operation to the chain and resets the builder for a new operation
|
||||
pub fn and(mut self) -> Self {
|
||||
self.add_current_operation();
|
||||
self
|
||||
}
|
||||
|
||||
// Helper method to add the current operation to the list
|
||||
fn add_current_operation(&mut self) -> bool {
|
||||
if let Some(pattern) = self.pattern.take() {
|
||||
let replacement = self.replacement.take().unwrap_or_default();
|
||||
let use_regex = self.use_regex;
|
||||
let case_insensitive = self.case_insensitive;
|
||||
|
||||
// Reset current settings
|
||||
self.use_regex = false;
|
||||
self.case_insensitive = false;
|
||||
|
||||
// Create the replacement mode
|
||||
let mode = if use_regex {
|
||||
let mut regex_pattern = pattern;
|
||||
|
||||
// If case insensitive, add the flag to the regex pattern
|
||||
if case_insensitive && !regex_pattern.starts_with("(?i)") {
|
||||
regex_pattern = format!("(?i){}", regex_pattern);
|
||||
}
|
||||
|
||||
match Regex::new(®ex_pattern) {
|
||||
Ok(re) => ReplaceMode::Regex(re),
|
||||
Err(_) => return false, // Failed to compile regex
|
||||
}
|
||||
} else {
|
||||
// For literal replacement, we'll handle case insensitivity differently
|
||||
// since String::replace doesn't have a case-insensitive option
|
||||
if case_insensitive {
|
||||
return false; // Case insensitive not supported for literal
|
||||
}
|
||||
ReplaceMode::Literal(pattern)
|
||||
};
|
||||
|
||||
self.operations
|
||||
.push(ReplacementOperation { mode, replacement });
|
||||
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the TextReplacer with all configured replacement operations
|
||||
pub fn build(mut self) -> Result<TextReplacer, String> {
|
||||
// If there's a pending replacement operation, add it
|
||||
self.add_current_operation();
|
||||
|
||||
// Ensure we have at least one replacement operation
|
||||
if self.operations.is_empty() {
|
||||
return Err("No replacement operations configured".to_string());
|
||||
}
|
||||
|
||||
Ok(TextReplacer {
|
||||
operations: self.operations,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::{Seek, SeekFrom, Write};
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
#[test]
|
||||
fn test_regex_replace() {
|
||||
let replacer = TextReplacer::builder()
|
||||
.pattern(r"\bfoo\b")
|
||||
.replacement("bar")
|
||||
.regex(true)
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let input = "foo bar foo baz";
|
||||
let output = replacer.replace(input);
|
||||
|
||||
assert_eq!(output, "bar bar bar baz");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_literal_replace() {
|
||||
let replacer = TextReplacer::builder()
|
||||
.pattern("foo")
|
||||
.replacement("qux")
|
||||
.regex(false)
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let input = "foo bar foo baz";
|
||||
let output = replacer.replace(input);
|
||||
|
||||
assert_eq!(output, "qux bar qux baz");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_replacements() {
|
||||
let replacer = TextReplacer::builder()
|
||||
.pattern("foo")
|
||||
.replacement("qux")
|
||||
.and()
|
||||
.pattern("bar")
|
||||
.replacement("baz")
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let input = "foo bar foo";
|
||||
let output = replacer.replace(input);
|
||||
|
||||
assert_eq!(output, "qux baz qux");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_case_insensitive_regex() {
|
||||
let replacer = TextReplacer::builder()
|
||||
.pattern("foo")
|
||||
.replacement("bar")
|
||||
.regex(true)
|
||||
.case_insensitive(true)
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let input = "FOO foo Foo";
|
||||
let output = replacer.replace(input);
|
||||
|
||||
assert_eq!(output, "bar bar bar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_operations() -> io::Result<()> {
|
||||
// Create a temporary file
|
||||
let mut temp_file = NamedTempFile::new()?;
|
||||
writeln!(temp_file, "foo bar foo baz")?;
|
||||
|
||||
// Flush the file to ensure content is written
|
||||
temp_file.as_file_mut().flush()?;
|
||||
|
||||
let replacer = TextReplacer::builder()
|
||||
.pattern("foo")
|
||||
.replacement("qux")
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
// Test replace_file
|
||||
let result = replacer.replace_file(temp_file.path())?;
|
||||
assert_eq!(result, "qux bar qux baz\n");
|
||||
|
||||
// Test replace_file_in_place
|
||||
replacer.replace_file_in_place(temp_file.path())?;
|
||||
|
||||
// Verify the file was updated - need to seek to beginning of file first
|
||||
let mut content = String::new();
|
||||
temp_file.as_file_mut().seek(SeekFrom::Start(0))?;
|
||||
temp_file.as_file_mut().read_to_string(&mut content)?;
|
||||
assert_eq!(content, "qux bar qux baz\n");
|
||||
|
||||
// Test replace_file_to with a new temporary file
|
||||
let output_file = NamedTempFile::new()?;
|
||||
replacer.replace_file_to(temp_file.path(), output_file.path())?;
|
||||
|
||||
// Verify the output file has the replaced content
|
||||
let mut output_content = String::new();
|
||||
fs::File::open(output_file.path())?.read_to_string(&mut output_content)?;
|
||||
assert_eq!(output_content, "qux bar qux baz\n");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
228
text/src/rhai.rs
Normal file
228
text/src/rhai.rs
Normal file
@@ -0,0 +1,228 @@
|
||||
//! Rhai wrappers for Text module functions
|
||||
//!
|
||||
//! This module provides Rhai wrappers for the functions in the Text module.
|
||||
|
||||
use crate::{TemplateBuilder, TextReplacer, TextReplacerBuilder};
|
||||
use rhai::{Array, Engine, EvalAltResult, Map, Position};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Register Text module functions with the Rhai engine
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `engine` - The Rhai engine to register the functions with
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Result<(), Box<EvalAltResult>>` - Ok if registration was successful, Err otherwise
|
||||
pub fn register_text_module(engine: &mut Engine) -> Result<(), Box<EvalAltResult>> {
|
||||
// Register types
|
||||
register_text_types(engine)?;
|
||||
|
||||
// Register TextReplacer constructor
|
||||
engine.register_fn("text_replacer_new", text_replacer_new);
|
||||
|
||||
// Register TextReplacerBuilder instance methods
|
||||
engine.register_fn("pattern", pattern);
|
||||
engine.register_fn("replacement", replacement);
|
||||
engine.register_fn("regex", regex);
|
||||
engine.register_fn("case_insensitive", case_insensitive);
|
||||
engine.register_fn("and", and);
|
||||
engine.register_fn("build", build);
|
||||
|
||||
// Register TextReplacer instance methods
|
||||
engine.register_fn("replace", replace);
|
||||
engine.register_fn("replace_file", replace_file);
|
||||
engine.register_fn("replace_file_in_place", replace_file_in_place);
|
||||
engine.register_fn("replace_file_to", replace_file_to);
|
||||
|
||||
// Register TemplateBuilder constructor
|
||||
engine.register_fn("template_builder_open", template_builder_open);
|
||||
|
||||
// Register TemplateBuilder instance methods
|
||||
engine.register_fn("add_var", add_var_string);
|
||||
engine.register_fn("add_var", add_var_int);
|
||||
engine.register_fn("add_var", add_var_float);
|
||||
engine.register_fn("add_var", add_var_bool);
|
||||
engine.register_fn("add_var", add_var_array);
|
||||
engine.register_fn("add_vars", add_vars);
|
||||
engine.register_fn("render", render);
|
||||
engine.register_fn("render_to_file", render_to_file);
|
||||
|
||||
// Register Fix functions directly from text module
|
||||
engine.register_fn("name_fix", crate::name_fix);
|
||||
engine.register_fn("path_fix", crate::path_fix);
|
||||
|
||||
// Register Dedent functions directly from text module
|
||||
engine.register_fn("dedent", crate::dedent);
|
||||
engine.register_fn("prefix", crate::prefix);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Register Text module types with the Rhai engine
|
||||
fn register_text_types(engine: &mut Engine) -> Result<(), Box<EvalAltResult>> {
|
||||
// Register TextReplacerBuilder type
|
||||
engine.register_type_with_name::<TextReplacerBuilder>("TextReplacerBuilder");
|
||||
|
||||
// Register TextReplacer type
|
||||
engine.register_type_with_name::<TextReplacer>("TextReplacer");
|
||||
|
||||
// Register TemplateBuilder type
|
||||
engine.register_type_with_name::<TemplateBuilder>("TemplateBuilder");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Helper functions for error conversion
|
||||
fn io_error_to_rhai_error<T>(result: std::io::Result<T>) -> Result<T, Box<EvalAltResult>> {
|
||||
result.map_err(|e| {
|
||||
Box::new(EvalAltResult::ErrorRuntime(
|
||||
format!("IO error: {}", e).into(),
|
||||
Position::NONE,
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn tera_error_to_rhai_error<T>(result: Result<T, tera::Error>) -> Result<T, Box<EvalAltResult>> {
|
||||
result.map_err(|e| {
|
||||
Box::new(EvalAltResult::ErrorRuntime(
|
||||
format!("Template error: {}", e).into(),
|
||||
Position::NONE,
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn string_error_to_rhai_error<T>(result: Result<T, String>) -> Result<T, Box<EvalAltResult>> {
|
||||
result.map_err(|e| Box::new(EvalAltResult::ErrorRuntime(e.into(), Position::NONE)))
|
||||
}
|
||||
|
||||
// TextReplacer implementation
|
||||
|
||||
/// Creates a new TextReplacerBuilder
|
||||
pub fn text_replacer_new() -> TextReplacerBuilder {
|
||||
TextReplacerBuilder::default()
|
||||
}
|
||||
|
||||
/// Sets the pattern to search for
|
||||
pub fn pattern(builder: TextReplacerBuilder, pat: &str) -> TextReplacerBuilder {
|
||||
builder.pattern(pat)
|
||||
}
|
||||
|
||||
/// Sets the replacement text
|
||||
pub fn replacement(builder: TextReplacerBuilder, rep: &str) -> TextReplacerBuilder {
|
||||
builder.replacement(rep)
|
||||
}
|
||||
|
||||
/// Sets whether to use regex
|
||||
pub fn regex(builder: TextReplacerBuilder, yes: bool) -> TextReplacerBuilder {
|
||||
builder.regex(yes)
|
||||
}
|
||||
|
||||
/// Sets whether the replacement should be case-insensitive
|
||||
pub fn case_insensitive(builder: TextReplacerBuilder, yes: bool) -> TextReplacerBuilder {
|
||||
builder.case_insensitive(yes)
|
||||
}
|
||||
|
||||
/// Adds another replacement operation to the chain and resets the builder for a new operation
|
||||
pub fn and(builder: TextReplacerBuilder) -> TextReplacerBuilder {
|
||||
builder.and()
|
||||
}
|
||||
|
||||
/// Builds the TextReplacer with all configured replacement operations
|
||||
pub fn build(builder: TextReplacerBuilder) -> Result<TextReplacer, Box<EvalAltResult>> {
|
||||
string_error_to_rhai_error(builder.build())
|
||||
}
|
||||
|
||||
/// Applies all configured replacement operations to the input text
|
||||
pub fn replace(replacer: &mut TextReplacer, input: &str) -> String {
|
||||
replacer.replace(input)
|
||||
}
|
||||
|
||||
/// Reads a file, applies all replacements, and returns the result as a string
|
||||
pub fn replace_file(replacer: &mut TextReplacer, path: &str) -> Result<String, Box<EvalAltResult>> {
|
||||
io_error_to_rhai_error(replacer.replace_file(path))
|
||||
}
|
||||
|
||||
/// Reads a file, applies all replacements, and writes the result back to the file
|
||||
pub fn replace_file_in_place(
|
||||
replacer: &mut TextReplacer,
|
||||
path: &str,
|
||||
) -> Result<(), Box<EvalAltResult>> {
|
||||
io_error_to_rhai_error(replacer.replace_file_in_place(path))
|
||||
}
|
||||
|
||||
/// Reads a file, applies all replacements, and writes the result to a new file
|
||||
pub fn replace_file_to(
|
||||
replacer: &mut TextReplacer,
|
||||
input_path: &str,
|
||||
output_path: &str,
|
||||
) -> Result<(), Box<EvalAltResult>> {
|
||||
io_error_to_rhai_error(replacer.replace_file_to(input_path, output_path))
|
||||
}
|
||||
|
||||
// TemplateBuilder implementation
|
||||
|
||||
/// Creates a new TemplateBuilder with the specified template path
|
||||
pub fn template_builder_open(template_path: &str) -> Result<TemplateBuilder, Box<EvalAltResult>> {
|
||||
io_error_to_rhai_error(TemplateBuilder::open(template_path))
|
||||
}
|
||||
|
||||
/// Adds a string variable to the template context
|
||||
pub fn add_var_string(builder: TemplateBuilder, name: &str, value: &str) -> TemplateBuilder {
|
||||
builder.add_var(name, value)
|
||||
}
|
||||
|
||||
/// Adds an integer variable to the template context
|
||||
pub fn add_var_int(builder: TemplateBuilder, name: &str, value: i64) -> TemplateBuilder {
|
||||
builder.add_var(name, value)
|
||||
}
|
||||
|
||||
/// Adds a float variable to the template context
|
||||
pub fn add_var_float(builder: TemplateBuilder, name: &str, value: f64) -> TemplateBuilder {
|
||||
builder.add_var(name, value)
|
||||
}
|
||||
|
||||
/// Adds a boolean variable to the template context
|
||||
pub fn add_var_bool(builder: TemplateBuilder, name: &str, value: bool) -> TemplateBuilder {
|
||||
builder.add_var(name, value)
|
||||
}
|
||||
|
||||
/// Adds an array variable to the template context
|
||||
pub fn add_var_array(builder: TemplateBuilder, name: &str, array: Array) -> TemplateBuilder {
|
||||
// Convert Rhai Array to Vec<String>
|
||||
let vec: Vec<String> = array
|
||||
.iter()
|
||||
.filter_map(|v| v.clone().into_string().ok())
|
||||
.collect();
|
||||
|
||||
builder.add_var(name, vec)
|
||||
}
|
||||
|
||||
/// Adds multiple variables to the template context from a Map
|
||||
pub fn add_vars(builder: TemplateBuilder, vars: Map) -> TemplateBuilder {
|
||||
// Convert Rhai Map to Rust HashMap
|
||||
let mut hash_map = HashMap::new();
|
||||
|
||||
for (key, value) in vars.iter() {
|
||||
if let Ok(val_str) = value.clone().into_string() {
|
||||
hash_map.insert(key.to_string(), val_str);
|
||||
}
|
||||
}
|
||||
|
||||
// Add the variables
|
||||
builder.add_vars(hash_map)
|
||||
}
|
||||
|
||||
/// Renders the template with the current context
|
||||
pub fn render(builder: &mut TemplateBuilder) -> Result<String, Box<EvalAltResult>> {
|
||||
tera_error_to_rhai_error(builder.render())
|
||||
}
|
||||
|
||||
/// Renders the template and writes the result to a file
|
||||
pub fn render_to_file(
|
||||
builder: &mut TemplateBuilder,
|
||||
output_path: &str,
|
||||
) -> Result<(), Box<EvalAltResult>> {
|
||||
io_error_to_rhai_error(builder.render_to_file(output_path))
|
||||
}
|
310
text/src/template.rs
Normal file
310
text/src/template.rs
Normal file
@@ -0,0 +1,310 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use tera::{Context, Tera};
|
||||
|
||||
/// A builder for creating and rendering templates using the Tera template engine.
|
||||
#[derive(Clone)]
|
||||
pub struct TemplateBuilder {
|
||||
template_path: String,
|
||||
context: Context,
|
||||
tera: Option<Tera>,
|
||||
}
|
||||
|
||||
impl TemplateBuilder {
|
||||
/// Creates a new TemplateBuilder with the specified template path.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `template_path` - The path to the template file
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A new TemplateBuilder instance
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use sal::text::TemplateBuilder;
|
||||
///
|
||||
/// let builder = TemplateBuilder::open("templates/example.html");
|
||||
/// ```
|
||||
pub fn open<P: AsRef<Path>>(template_path: P) -> io::Result<Self> {
|
||||
let path_str = template_path.as_ref().to_string_lossy().to_string();
|
||||
|
||||
// Verify the template file exists
|
||||
if !Path::new(&path_str).exists() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
format!("Template file not found: {}", path_str),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
template_path: path_str,
|
||||
context: Context::new(),
|
||||
tera: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Adds a variable to the template context.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `name` - The name of the variable to add
|
||||
/// * `value` - The value to associate with the variable
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The builder instance for method chaining
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```no_run
|
||||
/// use sal::text::TemplateBuilder;
|
||||
///
|
||||
/// fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// let builder = TemplateBuilder::open("templates/example.html")?
|
||||
/// .add_var("title", "Hello World")
|
||||
/// .add_var("username", "John Doe");
|
||||
/// Ok(())
|
||||
/// }
|
||||
/// ```
|
||||
pub fn add_var<S, V>(mut self, name: S, value: V) -> Self
|
||||
where
|
||||
S: AsRef<str>,
|
||||
V: serde::Serialize,
|
||||
{
|
||||
self.context.insert(name.as_ref(), &value);
|
||||
self
|
||||
}
|
||||
|
||||
/// Adds multiple variables to the template context from a HashMap.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `vars` - A HashMap containing variable names and values
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The builder instance for method chaining
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```no_run
|
||||
/// use sal::text::TemplateBuilder;
|
||||
/// use std::collections::HashMap;
|
||||
///
|
||||
/// fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// let mut vars = HashMap::new();
|
||||
/// vars.insert("title", "Hello World");
|
||||
/// vars.insert("username", "John Doe");
|
||||
///
|
||||
/// let builder = TemplateBuilder::open("templates/example.html")?
|
||||
/// .add_vars(vars);
|
||||
/// Ok(())
|
||||
/// }
|
||||
/// ```
|
||||
pub fn add_vars<S, V>(mut self, vars: HashMap<S, V>) -> Self
|
||||
where
|
||||
S: AsRef<str>,
|
||||
V: serde::Serialize,
|
||||
{
|
||||
for (name, value) in vars {
|
||||
self.context.insert(name.as_ref(), &value);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Initializes the Tera template engine with the template file.
|
||||
///
|
||||
/// This method is called automatically by render() if not called explicitly.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The builder instance for method chaining
|
||||
fn initialize_tera(&mut self) -> Result<(), tera::Error> {
|
||||
if self.tera.is_none() {
|
||||
// Create a new Tera instance with just this template
|
||||
let mut tera = Tera::default();
|
||||
|
||||
// Read the template content
|
||||
let template_content = fs::read_to_string(&self.template_path)
|
||||
.map_err(|e| tera::Error::msg(format!("Failed to read template file: {}", e)))?;
|
||||
|
||||
// Add the template to Tera
|
||||
let template_name = Path::new(&self.template_path)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("template");
|
||||
|
||||
tera.add_raw_template(template_name, &template_content)?;
|
||||
self.tera = Some(tera);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Renders the template with the current context.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The rendered template as a string
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```no_run
|
||||
/// use sal::text::TemplateBuilder;
|
||||
///
|
||||
/// fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// let result = TemplateBuilder::open("templates/example.html")?
|
||||
/// .add_var("title", "Hello World")
|
||||
/// .add_var("username", "John Doe")
|
||||
/// .render()?;
|
||||
///
|
||||
/// println!("Rendered template: {}", result);
|
||||
/// Ok(())
|
||||
/// }
|
||||
/// ```
|
||||
pub fn render(&mut self) -> Result<String, tera::Error> {
|
||||
// Initialize Tera if not already done
|
||||
self.initialize_tera()?;
|
||||
|
||||
// Get the template name
|
||||
let template_name = Path::new(&self.template_path)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("template");
|
||||
|
||||
// Render the template
|
||||
let tera = self.tera.as_ref().unwrap();
|
||||
tera.render(template_name, &self.context)
|
||||
}
|
||||
|
||||
/// Renders the template and writes the result to a file.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `output_path` - The path where the rendered template should be written
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Result indicating success or failure
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```no_run
|
||||
/// use sal::text::TemplateBuilder;
|
||||
///
|
||||
/// fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// TemplateBuilder::open("templates/example.html")?
|
||||
/// .add_var("title", "Hello World")
|
||||
/// .add_var("username", "John Doe")
|
||||
/// .render_to_file("output.html")?;
|
||||
/// Ok(())
|
||||
/// }
|
||||
/// ```
|
||||
pub fn render_to_file<P: AsRef<Path>>(&mut self, output_path: P) -> io::Result<()> {
|
||||
let rendered = self.render().map_err(|e| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
format!("Template rendering error: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
fs::write(output_path, rendered)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
#[test]
|
||||
fn test_template_rendering() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Create a temporary template file
|
||||
let temp_file = NamedTempFile::new()?;
|
||||
let template_content = "Hello, {{ name }}! Welcome to {{ place }}.\n";
|
||||
fs::write(temp_file.path(), template_content)?;
|
||||
|
||||
// Create a template builder and add variables
|
||||
let mut builder = TemplateBuilder::open(temp_file.path())?;
|
||||
builder = builder.add_var("name", "John").add_var("place", "Rust");
|
||||
|
||||
// Render the template
|
||||
let result = builder.render()?;
|
||||
assert_eq!(result, "Hello, John! Welcome to Rust.\n");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_template_with_multiple_vars() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Create a temporary template file
|
||||
let temp_file = NamedTempFile::new()?;
|
||||
let template_content = "{% if show_greeting %}Hello, {{ name }}!{% endif %}\n{% for item in items %}{{ item }}{% if not loop.last %}, {% endif %}{% endfor %}\n";
|
||||
fs::write(temp_file.path(), template_content)?;
|
||||
|
||||
// Create a template builder and add variables
|
||||
let mut builder = TemplateBuilder::open(temp_file.path())?;
|
||||
|
||||
// Add variables including a boolean and a vector
|
||||
builder = builder
|
||||
.add_var("name", "Alice")
|
||||
.add_var("show_greeting", true)
|
||||
.add_var("items", vec!["apple", "banana", "cherry"]);
|
||||
|
||||
// Render the template
|
||||
let result = builder.render()?;
|
||||
assert_eq!(result, "Hello, Alice!\napple, banana, cherry\n");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_template_with_hashmap_vars() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Create a temporary template file
|
||||
let mut temp_file = NamedTempFile::new()?;
|
||||
writeln!(temp_file, "{{{{ greeting }}}}, {{{{ name }}}}!")?;
|
||||
temp_file.flush()?;
|
||||
|
||||
// Create a HashMap of variables
|
||||
let mut vars = HashMap::new();
|
||||
vars.insert("greeting", "Hi");
|
||||
vars.insert("name", "Bob");
|
||||
|
||||
// Create a template builder and add variables from HashMap
|
||||
let mut builder = TemplateBuilder::open(temp_file.path())?;
|
||||
builder = builder.add_vars(vars);
|
||||
|
||||
// Render the template
|
||||
let result = builder.render()?;
|
||||
assert_eq!(result, "Hi, Bob!\n");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
#[test]
|
||||
fn test_render_to_file() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Create a temporary template file
|
||||
let temp_file = NamedTempFile::new()?;
|
||||
let template_content = "{{ message }}\n";
|
||||
fs::write(temp_file.path(), template_content)?;
|
||||
|
||||
// Create an output file
|
||||
let output_file = NamedTempFile::new()?;
|
||||
|
||||
// Create a template builder, add a variable, and render to file
|
||||
let mut builder = TemplateBuilder::open(temp_file.path())?;
|
||||
builder = builder.add_var("message", "This is a test");
|
||||
builder.render_to_file(output_file.path())?;
|
||||
|
||||
// Read the output file and verify its contents
|
||||
let content = fs::read_to_string(output_file.path())?;
|
||||
assert_eq!(content, "This is a test\n");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user