Decouple core Yaak logic from Tauri (#354)

This commit is contained in:
Gregory Schier
2026-01-08 20:44:25 -08:00
committed by GitHub
parent 3bcc0b8356
commit ef80216ca1
465 changed files with 3052 additions and 6234 deletions

View File

@@ -0,0 +1,22 @@
[package]
name = "yaak-templates"
version = "0.1.0"
edition = "2024"
publish = false
[package.metadata.wasm-pack.profile.release]
wasm-opt = false # Causes errors in CI (haven't figured out why yet)
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
base64 = "0.22.1"
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt"] }
ts-rs = { workspace = true }
wasm-bindgen = { version = "0.2.100", features = ["serde-serialize"] }
serde-wasm-bindgen = "0.6.5"
log = { workspace = true }

View File

@@ -0,0 +1,9 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type FnArg = { name: string, value: Val, };
export type Token = { "type": "raw", text: string, } | { "type": "tag", val: Val, } | { "type": "eof" };
export type Tokens = { tokens: Array<Token>, };
export type Val = { "type": "str", text: string, } | { "type": "var", name: string, } | { "type": "bool", value: boolean, } | { "type": "fn", name: string, args: Array<FnArg>, } | { "type": "null" };

View File

@@ -0,0 +1,15 @@
export * from './bindings/parser';
import { Tokens } from './bindings/parser';
import { escape_template, parse_template, unescape_template } from './pkg';
export function parseTemplate(template: string) {
return parse_template(template) as Tokens;
}
export function escapeTemplate(template: string) {
return escape_template(template) as string;
}
export function unescapeTemplate(template: string) {
return unescape_template(template) as string;
}

View File

@@ -0,0 +1,15 @@
{
"name": "@yaakapp-internal/templates",
"private": true,
"version": "1.0.0",
"main": "index.ts",
"scripts": {
"bootstrap": "npm run build",
"build": "run-s build:*",
"build:pack": "wasm-pack build --target bundler",
"build:clean": "rimraf ./pkg/.gitignore"
},
"devDependencies": {
"rimraf": "^6.1.2"
}
}

17
crates/yaak-templates/pkg/package.json generated Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "yaak-templates",
"type": "module",
"version": "0.1.0",
"files": [
"yaak_templates_bg.wasm",
"yaak_templates.js",
"yaak_templates_bg.js",
"yaak_templates.d.ts"
],
"main": "yaak_templates.js",
"types": "yaak_templates.d.ts",
"sideEffects": [
"./yaak_templates.js",
"./snippets/*"
]
}

5
crates/yaak-templates/pkg/yaak_templates.d.ts generated vendored Normal file
View File

@@ -0,0 +1,5 @@
/* tslint:disable */
/* eslint-disable */
export function unescape_template(template: string): any;
export function parse_template(template: string): any;
export function escape_template(template: string): any;

View File

@@ -0,0 +1,5 @@
import * as wasm from "./yaak_templates_bg.wasm";
export * from "./yaak_templates_bg.js";
import { __wbg_set_wasm } from "./yaak_templates_bg.js";
__wbg_set_wasm(wasm);
wasm.__wbindgen_start();

View File

@@ -0,0 +1,251 @@
let wasm;
export function __wbg_set_wasm(val) {
wasm = val;
}
function debugString(val) {
// primitive types
const type = typeof val;
if (type == 'number' || type == 'boolean' || val == null) {
return `${val}`;
}
if (type == 'string') {
return `"${val}"`;
}
if (type == 'symbol') {
const description = val.description;
if (description == null) {
return 'Symbol';
} else {
return `Symbol(${description})`;
}
}
if (type == 'function') {
const name = val.name;
if (typeof name == 'string' && name.length > 0) {
return `Function(${name})`;
} else {
return 'Function';
}
}
// objects
if (Array.isArray(val)) {
const length = val.length;
let debug = '[';
if (length > 0) {
debug += debugString(val[0]);
}
for(let i = 1; i < length; i++) {
debug += ', ' + debugString(val[i]);
}
debug += ']';
return debug;
}
// Test for built-in
const builtInMatches = /\[object ([^\]]+)\]/.exec(toString.call(val));
let className;
if (builtInMatches && builtInMatches.length > 1) {
className = builtInMatches[1];
} else {
// Failed to match the standard '[object ClassName]'
return toString.call(val);
}
if (className == 'Object') {
// we're a user defined class or Object
// JSON.stringify avoids problems with cycles, and is generally much
// easier than looping through ownProperties of `val`.
try {
return 'Object(' + JSON.stringify(val) + ')';
} catch (_) {
return 'Object';
}
}
// errors
if (val instanceof Error) {
return `${val.name}: ${val.message}\n${val.stack}`;
}
// TODO we could test for more things here, like `Set`s and `Map`s.
return className;
}
let WASM_VECTOR_LEN = 0;
let cachedUint8ArrayMemory0 = null;
function getUint8ArrayMemory0() {
if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);
}
return cachedUint8ArrayMemory0;
}
const lTextEncoder = typeof TextEncoder === 'undefined' ? (0, module.require)('util').TextEncoder : TextEncoder;
let cachedTextEncoder = new lTextEncoder('utf-8');
const encodeString = (typeof cachedTextEncoder.encodeInto === 'function'
? function (arg, view) {
return cachedTextEncoder.encodeInto(arg, view);
}
: function (arg, view) {
const buf = cachedTextEncoder.encode(arg);
view.set(buf);
return {
read: arg.length,
written: buf.length
};
});
function passStringToWasm0(arg, malloc, realloc) {
if (realloc === undefined) {
const buf = cachedTextEncoder.encode(arg);
const ptr = malloc(buf.length, 1) >>> 0;
getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf);
WASM_VECTOR_LEN = buf.length;
return ptr;
}
let len = arg.length;
let ptr = malloc(len, 1) >>> 0;
const mem = getUint8ArrayMemory0();
let offset = 0;
for (; offset < len; offset++) {
const code = arg.charCodeAt(offset);
if (code > 0x7F) break;
mem[ptr + offset] = code;
}
if (offset !== len) {
if (offset !== 0) {
arg = arg.slice(offset);
}
ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0;
const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len);
const ret = encodeString(arg, view);
offset += ret.written;
ptr = realloc(ptr, len, offset, 1) >>> 0;
}
WASM_VECTOR_LEN = offset;
return ptr;
}
let cachedDataViewMemory0 = null;
function getDataViewMemory0() {
if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) {
cachedDataViewMemory0 = new DataView(wasm.memory.buffer);
}
return cachedDataViewMemory0;
}
const lTextDecoder = typeof TextDecoder === 'undefined' ? (0, module.require)('util').TextDecoder : TextDecoder;
let cachedTextDecoder = new lTextDecoder('utf-8', { ignoreBOM: true, fatal: true });
cachedTextDecoder.decode();
function getStringFromWasm0(ptr, len) {
ptr = ptr >>> 0;
return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
}
function takeFromExternrefTable0(idx) {
const value = wasm.__wbindgen_export_2.get(idx);
wasm.__externref_table_dealloc(idx);
return value;
}
/**
* @param {string} template
* @returns {any}
*/
export function unescape_template(template) {
const ptr0 = passStringToWasm0(template, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.unescape_template(ptr0, len0);
if (ret[2]) {
throw takeFromExternrefTable0(ret[1]);
}
return takeFromExternrefTable0(ret[0]);
}
/**
* @param {string} template
* @returns {any}
*/
export function parse_template(template) {
const ptr0 = passStringToWasm0(template, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.parse_template(ptr0, len0);
if (ret[2]) {
throw takeFromExternrefTable0(ret[1]);
}
return takeFromExternrefTable0(ret[0]);
}
/**
* @param {string} template
* @returns {any}
*/
export function escape_template(template) {
const ptr0 = passStringToWasm0(template, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.escape_template(ptr0, len0);
if (ret[2]) {
throw takeFromExternrefTable0(ret[1]);
}
return takeFromExternrefTable0(ret[0]);
}
export function __wbg_new_405e22f390576ce2() {
const ret = new Object();
return ret;
};
export function __wbg_new_78feb108b6472713() {
const ret = new Array();
return ret;
};
export function __wbg_set_37837023f3d740e8(arg0, arg1, arg2) {
arg0[arg1 >>> 0] = arg2;
};
export function __wbg_set_3f1d0b984ed272ed(arg0, arg1, arg2) {
arg0[arg1] = arg2;
};
export function __wbindgen_debug_string(arg0, arg1) {
const ret = debugString(arg1);
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len1 = WASM_VECTOR_LEN;
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
};
export function __wbindgen_init_externref_table() {
const table = wasm.__wbindgen_export_2;
const offset = table.grow(4);
table.set(0, undefined);
table.set(offset + 0, undefined);
table.set(offset + 1, null);
table.set(offset + 2, true);
table.set(offset + 3, false);
;
};
export function __wbindgen_string_new(arg0, arg1) {
const ret = getStringFromWasm0(arg0, arg1);
return ret;
};
export function __wbindgen_throw(arg0, arg1) {
throw new Error(getStringFromWasm0(arg0, arg1));
};

Binary file not shown.

11
crates/yaak-templates/pkg/yaak_templates_bg.wasm.d.ts generated vendored Normal file
View File

@@ -0,0 +1,11 @@
/* tslint:disable */
/* eslint-disable */
export const memory: WebAssembly.Memory;
export const escape_template: (a: number, b: number) => [number, number, number];
export const parse_template: (a: number, b: number) => [number, number, number];
export const unescape_template: (a: number, b: number) => [number, number, number];
export const __wbindgen_malloc: (a: number, b: number) => number;
export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
export const __wbindgen_export_2: WebAssembly.Table;
export const __externref_table_dealloc: (a: number) => void;
export const __wbindgen_start: () => void;

View File

@@ -0,0 +1,32 @@
use serde::{Serialize, Serializer};
use thiserror::Error;
use wasm_bindgen::JsValue;
#[derive(Error, Debug, PartialEq)]
pub enum Error {
#[error("Render Error: {0}")]
RenderError(String),
#[error("Render Error: Variable \"{0}\" is not defined in active environment")]
VariableNotFound(String),
#[error("Render Error: Max recursion depth exceeded")]
RenderStackExceededError,
}
impl Serialize for Error {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.to_string().as_ref())
}
}
impl Into<JsValue> for Error {
fn into(self) -> JsValue {
serde_wasm_bindgen::to_value(&self).unwrap()
}
}
pub type Result<T> = std::result::Result<T, Error>;

View File

@@ -0,0 +1,166 @@
pub fn escape_template(text: &str) -> String {
let mut result = String::with_capacity(text.len());
let chars: Vec<char> = text.chars().collect();
let mut i = 0;
while i < chars.len() {
// Check if we're at "${["
if i + 2 < chars.len() && chars[i] == '$' && chars[i + 1] == '{' && chars[i + 2] == '[' {
// Count preceding backslashes
let mut backslash_count = 0;
let mut j = i;
while j > 0 && chars[j - 1] == '\\' {
backslash_count += 1;
j -= 1;
}
// If odd number of backslashes, the $ is escaped
// If even number (including 0), the $ is not escaped
let already_escaped = backslash_count % 2 == 1;
if already_escaped {
// Already escaped, just add the current character
result.push(chars[i]);
} else {
// Not escaped, add backslash before $
result.push('\\');
result.push(chars[i]);
}
} else {
result.push(chars[i]);
}
i += 1;
}
result
}
pub fn unescape_template(text: &str) -> String {
let mut result = String::with_capacity(text.len());
let chars: Vec<char> = text.chars().collect();
let mut i = 0;
while i < chars.len() {
// Check if we're at "\${["
if i + 3 < chars.len()
&& chars[i] == '\\'
&& chars[i + 1] == '$'
&& chars[i + 2] == '{'
&& chars[i + 3] == '['
{
// Count preceding backslashes (before the current backslash)
let mut backslash_count = 0;
let mut j = i;
while j > 0 && chars[j - 1] == '\\' {
backslash_count += 1;
j -= 1;
}
// If even number of preceding backslashes, this backslash escapes the $
// If odd number, this backslash is itself escaped
let escapes_dollar = backslash_count % 2 == 0;
if escapes_dollar {
// Skip the backslash, just add the $
result.push(chars[i + 1]);
i += 1; // Skip the backslash
} else {
// This backslash is escaped itself, keep it
result.push(chars[i]);
}
} else {
result.push(chars[i]);
}
i += 1;
}
result
}
#[cfg(test)]
mod tests {
use crate::escape::{escape_template, unescape_template};
#[test]
fn test_escape_simple() {
let input = r#"${[foo]}"#;
let expected = r#"\${[foo]}"#;
assert_eq!(escape_template(input), expected);
}
#[test]
fn test_already_escaped() {
let input = r#"\${[bar]}"#;
let expected = r#"\${[bar]}"#;
assert_eq!(escape_template(input), expected);
}
#[test]
fn test_double_backslash() {
let input = r#"\\${[bar]}"#;
let expected = r#"\\\${[bar]}"#;
assert_eq!(escape_template(input), expected);
}
#[test]
fn test_escape_with_surrounding_text() {
let input = r#"text ${[var]} more"#;
let expected = r#"text \${[var]} more"#;
assert_eq!(escape_template(input), expected);
}
#[test]
fn test_preserve_already_escaped() {
let input = r#"already \${[escaped]}"#;
let expected = r#"already \${[escaped]}"#;
assert_eq!(escape_template(input), expected);
}
#[test]
fn test_multiple_occurrences() {
let input = r#"${[one]} and ${[two]}"#;
let expected = r#"\${[one]} and \${[two]}"#;
assert_eq!(escape_template(input), expected);
}
#[test]
fn test_mixed_escaped_and_unescaped() {
let input = r#"mixed \${[esc]} and ${[unesc]}"#;
let expected = r#"mixed \${[esc]} and \${[unesc]}"#;
assert_eq!(escape_template(input), expected);
}
#[test]
fn test_unescape_simple() {
let input = r#"\${[foo]}"#;
let expected = r#"${[foo]}"#;
assert_eq!(unescape_template(input), expected);
}
#[test]
fn test_unescape_with_text() {
let input = r#"text \${[var]} more"#;
let expected = r#"text ${[var]} more"#;
assert_eq!(unescape_template(input), expected);
}
#[test]
fn test_unescape_multiple() {
let input = r#"\${[one]} and \${[two]}"#;
let expected = r#"${[one]} and ${[two]}"#;
assert_eq!(unescape_template(input), expected);
}
#[test]
fn test_unescape_double_backslash() {
let input = r#"\\\${[bar]}"#;
let expected = r#"\\${[bar]}"#;
assert_eq!(unescape_template(input), expected);
}
#[test]
fn test_unescape_plain_text() {
let input = r#"${[foo]}"#;
let expected = r#"${[foo]}"#;
assert_eq!(unescape_template(input), expected);
}
}

View File

@@ -0,0 +1,304 @@
enum FormatState {
TemplateTag,
String,
None,
}
/// Formats JSON that might contain template tags (skipped entirely)
pub fn format_json(text: &str, tab: &str) -> String {
let mut chars = text.chars().peekable();
let mut new_json = "".to_string();
let mut depth = 0;
let mut state = FormatState::None;
loop {
let rest_of_chars = chars.clone();
let current_char = match chars.next() {
None => break,
Some(c) => c,
};
// Handle JSON string states
if let FormatState::String = state {
match current_char {
'"' => {
state = FormatState::None;
new_json.push(current_char);
continue;
}
'\\' => {
new_json.push(current_char);
if let Some(c) = chars.next() {
new_json.push(c);
}
continue;
}
_ => {
new_json.push(current_char);
continue;
}
}
}
// Close Template tag states
if let FormatState::TemplateTag = state {
if rest_of_chars.take(2).collect::<String>() == "]}" {
state = FormatState::None;
new_json.push_str("]}");
chars.next(); // Skip the second closing bracket
continue;
} else {
new_json.push(current_char);
continue;
}
}
if rest_of_chars.take(3).collect::<String>() == "${[" {
state = FormatState::TemplateTag;
new_json.push_str("${[");
chars.next(); // Skip {
chars.next(); // Skip [
continue;
}
match current_char {
',' => {
new_json.push(current_char);
new_json.push('\n');
new_json.push_str(tab.to_string().repeat(depth).as_str());
}
'{' => match chars.peek() {
Some('}') => {
new_json.push(current_char);
new_json.push('}');
chars.next(); // Skip }
}
_ => {
depth += 1;
new_json.push(current_char);
new_json.push('\n');
new_json.push_str(tab.to_string().repeat(depth).as_str());
}
},
'[' => match chars.peek() {
Some(']') => {
new_json.push(current_char);
new_json.push(']');
chars.next(); // Skip ]
}
_ => {
depth += 1;
new_json.push(current_char);
new_json.push('\n');
new_json.push_str(tab.to_string().repeat(depth).as_str());
}
},
'}' => {
// Guard just in case invalid JSON has more closes than opens
if depth > 0 {
depth -= 1;
}
new_json.push('\n');
new_json.push_str(tab.to_string().repeat(depth).as_str());
new_json.push(current_char);
}
']' => {
// Guard just in case invalid JSON has more closes than opens
if depth > 0 {
depth -= 1;
}
new_json.push('\n');
new_json.push_str(tab.to_string().repeat(depth).as_str());
new_json.push(current_char);
}
':' => {
new_json.push(current_char);
new_json.push(' '); // Pad with space
}
'"' => {
state = FormatState::String;
new_json.push(current_char);
}
_ => {
if current_char == ' '
|| current_char == '\n'
|| current_char == '\t'
|| current_char == '\r'
{
// Don't add these
} else {
new_json.push(current_char);
}
}
}
}
// Replace only lines containing whitespace with nothing
new_json
.lines()
.filter(|line| !line.trim().is_empty()) // Filter out whitespace-only lines
.collect::<Vec<&str>>() // Collect the non-empty lines into a vector
.join("\n") // Join the lines back into a single string
}
#[cfg(test)]
mod tests {
use crate::format_json::format_json;
#[test]
fn test_simple_object() {
assert_eq!(
format_json(r#"{"foo":"bar","baz":"qux"}"#, " "),
r#"
{
"foo": "bar",
"baz": "qux"
}
"#
.trim()
);
}
#[test]
fn test_escaped() {
assert_eq!(
format_json(r#"{"foo":"Hi \"world!\""}"#, " "),
r#"
{
"foo": "Hi \"world!\""
}
"#
.trim()
);
}
#[test]
fn test_simple_array() {
assert_eq!(
format_json(r#"["foo","bar","baz","qux"]"#, " "),
r#"
[
"foo",
"bar",
"baz",
"qux"
]
"#
.trim()
);
}
#[test]
fn test_extra_whitespace() {
assert_eq!(
format_json(
r#"["foo", "bar", "baz","qux"
]"#,
" "
),
r#"
[
"foo",
"bar",
"baz",
"qux"
]
"#
.trim()
);
}
#[test]
fn test_invalid_json() {
assert_eq!(
format_json(r#"["foo", {"bar", }"baz",["qux" ]]"#, " "),
r#"
[
"foo",
{
"bar",
}"baz",
[
"qux"
]
]
"#
.trim()
);
}
#[test]
fn test_skip_template_tags() {
assert_eq!(
format_json(r#"{"foo":${[ fn("hello", "world") ]} }"#, " "),
r#"
{
"foo": ${[ fn("hello", "world") ]}
}
"#
.trim()
);
}
#[test]
fn test_graphql_response() {
assert_eq!(
format_json(
r#"{"data":{"capsules":[{"landings":null,"original_launch":null,"reuse_count":0,"status":"retired","type":"Dragon 1.0","missions":null},{"id":"5e9e2c5bf3591882af3b2665","landings":null,"original_launch":null,"reuse_count":0,"status":"retired","type":"Dragon 1.0","missions":null}]}}"#,
" "
),
r#"
{
"data": {
"capsules": [
{
"landings": null,
"original_launch": null,
"reuse_count": 0,
"status": "retired",
"type": "Dragon 1.0",
"missions": null
},
{
"id": "5e9e2c5bf3591882af3b2665",
"landings": null,
"original_launch": null,
"reuse_count": 0,
"status": "retired",
"type": "Dragon 1.0",
"missions": null
}
]
}
}
"#
.trim()
);
}
#[test]
fn test_immediate_close() {
assert_eq!(
format_json(r#"{"bar":[]}"#, " "),
r#"
{
"bar": []
}
"#
.trim()
);
}
#[test]
fn test_more_closes() {
assert_eq!(
format_json(r#"{}}"#, " "),
r#"
{}
}
"#
.trim()
);
}
}

View File

@@ -0,0 +1,9 @@
pub mod error;
pub mod escape;
pub mod format_json;
pub mod parser;
pub mod renderer;
pub mod wasm;
pub use parser::*;
pub use renderer::*;

View File

@@ -0,0 +1,904 @@
use crate::TemplateCallback;
use crate::error::Error::RenderError;
use crate::error::Result;
use base64::Engine;
use base64::prelude::BASE64_URL_SAFE_NO_PAD;
use serde::{Deserialize, Serialize};
use std::fmt::Display;
use ts_rs::TS;
#[derive(Default, Clone, PartialEq, Debug, Serialize, Deserialize, TS)]
#[ts(export, export_to = "parser.ts")]
pub struct Tokens {
pub tokens: Vec<Token>,
}
impl Display for Tokens {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let str = self.tokens.iter().map(|t| t.to_string()).collect::<Vec<String>>().join("");
write!(f, "{}", str)
}
}
#[derive(Clone, PartialEq, Debug, Serialize, Deserialize, TS)]
#[ts(export, export_to = "parser.ts")]
pub struct FnArg {
pub name: String,
pub value: Val,
}
impl Display for FnArg {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let str = format!("{}={}", self.name, self.value);
write!(f, "{}", str)
}
}
#[derive(Clone, PartialEq, Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case", tag = "type")]
#[ts(export, export_to = "parser.ts")]
pub enum Val {
Str { text: String },
Var { name: String },
Bool { value: bool },
Fn { name: String, args: Vec<FnArg> },
Null,
}
impl Display for Val {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let str = match self {
Val::Str { text } => {
if text.chars().all(|c| c.is_alphanumeric() || c == ' ' || c == '_' || c == '_') {
format!("'{}'", text)
} else {
format!("b64'{}'", BASE64_URL_SAFE_NO_PAD.encode(text))
}
}
Val::Var { name } => name.to_string(),
Val::Bool { value } => value.to_string(),
Val::Fn { name, args } => {
format!(
"{name}({})",
args.iter()
.filter_map(|a| match a.value.clone() {
Val::Null => None,
_ => Some(a.to_string()),
})
.collect::<Vec<String>>()
.join(", ")
)
}
Val::Null => "null".to_string(),
};
write!(f, "{}", str)
}
}
#[derive(Clone, PartialEq, Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case", tag = "type")]
#[ts(export, export_to = "parser.ts")]
pub enum Token {
Raw { text: String },
Tag { val: Val },
Eof,
}
impl Display for Token {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let str = match self {
Token::Raw { text } => text.to_string(),
Token::Tag { val } => format!("${{[ {} ]}}", val.to_string()),
Token::Eof => "".to_string(),
};
write!(f, "{}", str)
}
}
fn transform_val<T: TemplateCallback>(val: &Val, cb: &T) -> Result<Val> {
let val = match val {
Val::Fn { name: fn_name, args } => {
let mut new_args: Vec<FnArg> = Vec::new();
for arg in args {
let value = match arg.clone().value {
Val::Str { text } => {
let text = cb.transform_arg(&fn_name, &arg.name, &text)?;
Val::Str { text }
}
v => transform_val(&v, cb)?,
};
let arg_name = arg.name.clone();
new_args.push(FnArg { name: arg_name, value });
}
Val::Fn { name: fn_name.clone(), args: new_args }
}
_ => val.clone(),
};
Ok(val)
}
pub fn transform_args<T: TemplateCallback>(tokens: Tokens, cb: &T) -> Result<Tokens> {
let mut new_tokens = Tokens::default();
for t in tokens.tokens.iter() {
new_tokens.tokens.push(match t {
Token::Tag { val } => {
let val = transform_val(val, cb)?;
Token::Tag { val }
}
_ => t.clone(),
});
}
Ok(new_tokens)
}
// Template Syntax
//
// ${[ my_var ]}
// ${[ my_fn() ]}
// ${[ my_fn(my_var) ]}
// ${[ my_fn(my_var, "A String") ]}
// default
#[derive(Default)]
pub struct Parser {
tokens: Vec<Token>,
chars: Vec<char>,
pos: usize,
curr_text: String,
}
impl Parser {
pub fn new(text: &str) -> Parser {
Parser { chars: text.chars().collect(), ..Parser::default() }
}
pub fn parse(&mut self) -> Result<Tokens> {
let start_pos = self.pos;
while self.pos < self.chars.len() {
if self.match_str(r#"\\"#) {
// Skip double-escapes so we don't trigger our own escapes in the next case
self.curr_text += r#"\\"#;
} else if self.match_str(r#"\${["#) {
// Unescaped template syntax so we treat it as a string
self.curr_text += "${[";
} else if self.match_str("${[") {
let start_curr = self.pos;
if let Some(t) = self.parse_tag()? {
self.push_token(t);
} else {
self.pos = start_curr;
self.curr_text += "${[";
}
} else {
let ch = self.next_char();
self.curr_text.push(ch);
}
if start_pos == self.pos {
panic!("Parser stuck!");
}
}
self.push_token(Token::Eof);
Ok(Tokens { tokens: self.tokens.clone() })
}
fn parse_tag(&mut self) -> Result<Option<Token>> {
// Parse up to first identifier
// ${[ my_var...
self.skip_whitespace();
let val = match self.parse_value()? {
Some(v) => v,
None => return Ok(None),
};
// Parse to closing tag
// ${[ my_var(a, b, c) ]}
self.skip_whitespace();
if !self.match_str("]}") {
return Ok(None);
}
Ok(Some(Token::Tag { val }))
}
#[allow(dead_code)]
fn debug_pos(&self, x: &str) {
println!(
r#"Position: {x}: text[{}]='{}' → "{}" → {:?}"#,
self.pos,
self.chars[self.pos],
self.chars.iter().collect::<String>(),
self.tokens,
);
}
fn parse_value(&mut self) -> Result<Option<Val>> {
let v = if let Some((name, args)) = self.parse_fn()? {
Some(Val::Fn { name, args })
} else if let Some(v) = self.parse_string()? {
Some(Val::Str { text: v })
} else if let Some(v) = self.parse_ident() {
if v == "null" {
Some(Val::Null)
} else if v == "true" {
Some(Val::Bool { value: true })
} else if v == "false" {
Some(Val::Bool { value: false })
} else {
Some(Val::Var { name: v })
}
} else {
None
};
Ok(v)
}
fn parse_fn(&mut self) -> Result<Option<(String, Vec<FnArg>)>> {
let start_pos = self.pos;
let name = match self.parse_fn_name() {
Some(v) => v,
None => {
self.pos = start_pos;
return Ok(None);
}
};
let args = match self.parse_fn_args()? {
Some(args) => args,
None => {
self.pos = start_pos;
return Ok(None);
}
};
Ok(Some((name, args)))
}
fn parse_fn_args(&mut self) -> Result<Option<Vec<FnArg>>> {
if !self.match_str("(") {
return Ok(None);
}
let start_pos = self.pos;
let mut args: Vec<FnArg> = Vec::new();
// Fn closed immediately
self.skip_whitespace();
if self.match_str(")") {
return Ok(Some(args));
}
while self.pos < self.chars.len() {
self.skip_whitespace();
let name = self.parse_ident();
self.skip_whitespace();
self.match_str("=");
self.skip_whitespace();
let value = self.parse_value()?;
self.skip_whitespace();
if let (Some(name), Some(value)) = (name.clone(), value.clone()) {
args.push(FnArg { name, value });
} else {
// Didn't find valid thing, so return
self.pos = start_pos;
return Ok(None);
}
if self.match_str(")") {
break;
}
self.skip_whitespace();
// If we don't find a comma, that's bad
if !args.is_empty() && !self.match_str(",") {
self.pos = start_pos;
return Ok(None);
}
if start_pos == self.pos {
panic!("Parser stuck!");
}
}
Ok(Some(args))
}
fn parse_ident(&mut self) -> Option<String> {
let start_pos = self.pos;
let mut text = String::new();
while self.pos < self.chars.len() {
let ch = self.peek_char();
let is_valid = if start_pos == self.pos {
ch.is_alphanumeric() || ch == '_' // The first char is more restrictive
} else {
ch.is_alphanumeric() || ch == '_' || ch == '-' || ch == '.'
};
if is_valid {
text.push(ch);
self.pos += 1;
} else {
break;
}
if start_pos == self.pos {
panic!("Parser stuck!");
}
}
if text.is_empty() {
self.pos = start_pos;
return None;
}
Some(text)
}
fn parse_fn_name(&mut self) -> Option<String> {
let start_pos = self.pos;
let mut text = String::new();
while self.pos < self.chars.len() {
let ch = self.peek_char();
if ch.is_alphanumeric() || ch == '_' || ch == '.' {
text.push(ch);
self.pos += 1;
} else {
break;
}
if start_pos == self.pos {
panic!("Parser stuck!");
}
}
if text.is_empty() {
self.pos = start_pos;
return None;
}
Some(text)
}
fn parse_string(&mut self) -> Result<Option<String>> {
let start_pos = self.pos;
let mut text = String::new();
let mut is_b64 = false;
if self.match_str("b64'") {
is_b64 = true;
} else if self.match_str("'") {
// Nothing
} else {
return Ok(None);
}
let mut found_closing = false;
while self.pos < self.chars.len() {
let ch = self.next_char();
match ch {
'\\' => {
text.push(self.next_char());
}
'\'' => {
found_closing = true;
break;
}
_ => {
text.push(ch);
}
}
if start_pos == self.pos {
panic!("Parser stuck!");
}
}
if !found_closing {
self.pos = start_pos;
return Ok(None);
}
let final_text = if is_b64 {
let decoded = BASE64_URL_SAFE_NO_PAD
.decode(text.clone())
.map_err(|_| RenderError(format!("Failed to decode string {text}")))?;
let decoded = String::from_utf8(decoded)
.map_err(|_| RenderError(format!("Failed to decode utf8 string {text}")))?;
decoded
} else {
text
};
Ok(Some(final_text))
}
fn skip_whitespace(&mut self) {
while self.pos < self.chars.len() {
if self.peek_char().is_whitespace() {
self.pos += 1;
} else {
break;
}
}
}
fn next_char(&mut self) -> char {
let ch = self.peek_char();
self.pos += 1;
ch
}
fn peek_char(&self) -> char {
let ch = self.chars[self.pos];
ch
}
fn push_token(&mut self, token: Token) {
// Push any text we've accumulated
if !self.curr_text.is_empty() {
let text_token = Token::Raw { text: self.curr_text.clone() };
self.tokens.push(text_token);
self.curr_text.clear();
}
self.tokens.push(token);
}
fn match_str(&mut self, value: &str) -> bool {
if self.pos + value.len() > self.chars.len() {
return false;
}
let cmp = self.chars[self.pos..self.pos + value.len()].iter().collect::<String>();
if cmp == value {
// We have a match, so advance the current index
self.pos += value.len();
true
} else {
false
}
}
}
#[cfg(test)]
mod tests {
use crate::Val::Null;
use crate::error::Result;
use crate::*;
#[test]
fn escaped() -> Result<()> {
let mut p = Parser::new(r#"\${[ foo ]}"#);
assert_eq!(
p.parse()?.tokens,
vec![Token::Raw { text: "${[ foo ]}".to_string() }, Token::Eof]
);
Ok(())
}
#[test]
fn escaped_tricky() -> Result<()> {
let mut p = Parser::new(r#"\\${[ foo ]}"#);
assert_eq!(
p.parse()?.tokens,
vec![
Token::Raw { text: r#"\\"#.to_string() },
Token::Tag { val: Val::Var { name: "foo".into() } },
Token::Eof
]
);
Ok(())
}
#[test]
fn var_simple() -> Result<()> {
let mut p = Parser::new("${[ foo ]}");
assert_eq!(
p.parse()?.tokens,
vec![
Token::Tag { val: Val::Var { name: "foo".into() } },
Token::Eof
]
);
Ok(())
}
#[test]
fn var_dashes() -> Result<()> {
let mut p = Parser::new("${[ a-b ]}");
assert_eq!(
p.parse()?.tokens,
vec![
Token::Tag { val: Val::Var { name: "a-b".into() } },
Token::Eof
]
);
Ok(())
}
#[test]
fn var_underscores() -> Result<()> {
let mut p = Parser::new("${[ a_b ]}");
assert_eq!(
p.parse()?.tokens,
vec![
Token::Tag { val: Val::Var { name: "a_b".into() } },
Token::Eof
]
);
Ok(())
}
#[test]
fn var_dots() -> Result<()> {
let mut p = Parser::new("${[ a.b ]}");
assert_eq!(
p.parse()?.tokens,
vec![
Token::Tag { val: Val::Var { name: "a.b".into() } },
Token::Eof
]
);
Ok(())
}
#[test]
fn var_prefixes() -> Result<()> {
let mut p = Parser::new("${[ -a ]}${[ $a ]}");
assert_eq!(
p.parse()?.tokens,
vec![
Token::Raw {
// Shouldn't be parsed, because they're invalid
text: "${[ -a ]}${[ $a ]}".into()
},
Token::Eof
]
);
Ok(())
}
#[test]
fn var_underscore_prefix() -> Result<()> {
let mut p = Parser::new("${[ _a ]}");
assert_eq!(
p.parse()?.tokens,
vec![
Token::Tag { val: Val::Var { name: "_a".into() } },
Token::Eof
]
);
Ok(())
}
#[test]
fn var_boolean() -> Result<()> {
let mut p = Parser::new("${[ true ]}${[ false ]}");
assert_eq!(
p.parse()?.tokens,
vec![
Token::Tag { val: Val::Bool { value: true } },
Token::Tag { val: Val::Bool { value: false } },
Token::Eof
]
);
Ok(())
}
#[test]
fn var_multiple_names_invalid() -> Result<()> {
let mut p = Parser::new("${[ foo bar ]}");
assert_eq!(
p.parse()?.tokens,
vec![Token::Raw { text: "${[ foo bar ]}".into() }, Token::Eof]
);
Ok(())
}
#[test]
fn tag_string() -> Result<()> {
let mut p = Parser::new(r#"${[ 'foo \'bar\' baz' ]}"#);
assert_eq!(
p.parse()?.tokens,
vec![
Token::Tag { val: Val::Str { text: r#"foo 'bar' baz"#.into() } },
Token::Eof
]
);
Ok(())
}
#[test]
fn tag_b64_string() -> Result<()> {
let mut p = Parser::new(r#"${[ b64'Zm9vICdiYXInIGJheg' ]}"#);
assert_eq!(
p.parse()?.tokens,
vec![
Token::Tag { val: Val::Str { text: r#"foo 'bar' baz"#.into() } },
Token::Eof
]
);
Ok(())
}
#[test]
fn var_surrounded() -> Result<()> {
let mut p = Parser::new("Hello ${[ foo ]}!");
assert_eq!(
p.parse()?.tokens,
vec![
Token::Raw { text: "Hello ".to_string() },
Token::Tag { val: Val::Var { name: "foo".into() } },
Token::Raw { text: "!".to_string() },
Token::Eof,
]
);
Ok(())
}
#[test]
fn fn_simple() -> Result<()> {
let mut p = Parser::new("${[ foo() ]}");
assert_eq!(
p.parse()?.tokens,
vec![
Token::Tag { val: Val::Fn { name: "foo".into(), args: Vec::new() } },
Token::Eof
]
);
Ok(())
}
#[test]
fn fn_dot_name() -> Result<()> {
let mut p = Parser::new("${[ foo.bar.baz() ]}");
assert_eq!(
p.parse()?.tokens,
vec![
Token::Tag { val: Val::Fn { name: "foo.bar.baz".into(), args: Vec::new() } },
Token::Eof
]
);
Ok(())
}
#[test]
fn fn_ident_arg() -> Result<()> {
let mut p = Parser::new("${[ foo(a=bar) ]}");
assert_eq!(
p.parse()?.tokens,
vec![
Token::Tag {
val: Val::Fn {
name: "foo".into(),
args: vec![FnArg {
name: "a".into(),
value: Val::Var { name: "bar".into() }
}],
}
},
Token::Eof
]
);
Ok(())
}
#[test]
fn fn_ident_args() -> Result<()> {
let mut p = Parser::new("${[ foo(a=bar,b = baz, c =qux ) ]}");
assert_eq!(
p.parse()?.tokens,
vec![
Token::Tag {
val: Val::Fn {
name: "foo".into(),
args: vec![
FnArg { name: "a".into(), value: Val::Var { name: "bar".into() } },
FnArg { name: "b".into(), value: Val::Var { name: "baz".into() } },
FnArg { name: "c".into(), value: Val::Var { name: "qux".into() } },
],
}
},
Token::Eof
]
);
Ok(())
}
#[test]
fn fn_mixed_args() -> Result<()> {
let mut p = Parser::new(r#"${[ foo(aaa=bar,bb='baz \'hi\'', c=qux, z=true ) ]}"#);
assert_eq!(
p.parse()?.tokens,
vec![
Token::Tag {
val: Val::Fn {
name: "foo".into(),
args: vec![
FnArg { name: "aaa".into(), value: Val::Var { name: "bar".into() } },
FnArg {
name: "bb".into(),
value: Val::Str { text: r#"baz 'hi'"#.into() }
},
FnArg { name: "c".into(), value: Val::Var { name: "qux".into() } },
FnArg { name: "z".into(), value: Val::Bool { value: true } },
],
}
},
Token::Eof
]
);
Ok(())
}
#[test]
fn fn_nested() -> Result<()> {
let mut p = Parser::new("${[ foo(b=bar()) ]}");
assert_eq!(
p.parse()?.tokens,
vec![
Token::Tag {
val: Val::Fn {
name: "foo".into(),
args: vec![FnArg {
name: "b".into(),
value: Val::Fn { name: "bar".into(), args: vec![] }
}],
}
},
Token::Eof
]
);
Ok(())
}
#[test]
fn fn_nested_args() -> Result<()> {
let mut p = Parser::new(r#"${[ outer(a=inner(a=foo, b='i'), c='o') ]}"#);
assert_eq!(
p.parse()?.tokens,
vec![
Token::Tag {
val: Val::Fn {
name: "outer".into(),
args: vec![
FnArg {
name: "a".into(),
value: Val::Fn {
name: "inner".into(),
args: vec![
FnArg {
name: "a".into(),
value: Val::Var { name: "foo".into() }
},
FnArg {
name: "b".into(),
value: Val::Str { text: "i".into() },
},
],
}
},
FnArg { name: "c".into(), value: Val::Str { text: "o".into() } },
],
}
},
Token::Eof
]
);
Ok(())
}
#[test]
fn token_display_var() -> Result<()> {
assert_eq!(Val::Var { name: "foo".to_string() }.to_string(), "foo");
Ok(())
}
#[test]
fn token_display_str() -> Result<()> {
assert_eq!(Val::Str { text: "Hello You".to_string() }.to_string(), "'Hello You'");
Ok(())
}
#[test]
fn token_display_complex_str() -> Result<()> {
assert_eq!(
Val::Str { text: "Hello 'You'".to_string() }.to_string(),
"b64'SGVsbG8gJ1lvdSc'"
);
Ok(())
}
#[test]
fn token_null_fn_arg() -> Result<()> {
assert_eq!(
Val::Fn {
name: "fn".to_string(),
args: vec![
FnArg { name: "n".to_string(), value: Null },
FnArg { name: "a".to_string(), value: Val::Str { text: "aaa".to_string() } }
]
}
.to_string(),
r#"fn(a='aaa')"#
);
Ok(())
}
#[test]
fn token_display_fn() -> Result<()> {
assert_eq!(
Token::Tag {
val: Val::Fn {
name: "foo".to_string(),
args: vec![
FnArg {
name: "arg".to_string(),
value: Val::Str { text: "v 'x'".to_string() }
},
FnArg {
name: "arg2".to_string(),
value: Val::Var { name: "my_var".to_string() }
}
]
}
}
.to_string(),
r#"${[ foo(arg=b64'diAneCc', arg2=my_var) ]}"#
);
Ok(())
}
#[test]
fn tokens_display() -> Result<()> {
assert_eq!(
Tokens {
tokens: vec![
Token::Tag { val: Val::Var { name: "my_var".to_string() } },
Token::Raw { text: " Some cool text ".to_string() },
Token::Tag { val: Val::Str { text: "Hello World".to_string() } }
]
}
.to_string(),
r#"${[ my_var ]} Some cool text ${[ 'Hello World' ]}"#
);
Ok(())
}
}

View File

@@ -0,0 +1,639 @@
use crate::error::Error::{RenderStackExceededError, VariableNotFound};
use crate::error::Result;
use crate::{Parser, Token, Tokens, Val};
use log::warn;
use serde_json::json;
use std::collections::HashMap;
use std::future::Future;
const MAX_DEPTH: usize = 50;
pub trait TemplateCallback {
fn run(
&self,
fn_name: &str,
args: HashMap<String, serde_json::Value>,
) -> impl Future<Output = Result<String>> + Send;
fn transform_arg(&self, fn_name: &str, arg_name: &str, arg_value: &str) -> Result<String>;
}
pub async fn render_json_value_raw<T: TemplateCallback>(
v: serde_json::Value,
vars: &HashMap<String, String>,
cb: &T,
opt: &RenderOptions,
) -> Result<serde_json::Value> {
let v = match v {
serde_json::Value::String(s) => json!(parse_and_render(&s, vars, cb, opt).await?),
serde_json::Value::Array(a) => {
let mut new_a = Vec::new();
for v in a {
new_a.push(Box::pin(render_json_value_raw(v, vars, cb, opt)).await?)
}
json!(new_a)
}
serde_json::Value::Object(o) => {
let mut new_o = serde_json::Map::new();
for (k, v) in o {
let key = Box::pin(parse_and_render(&k, vars, cb, opt)).await?;
let value = Box::pin(render_json_value_raw(v, vars, cb, opt)).await?;
new_o.insert(key, value);
}
json!(new_o)
}
v => v,
};
Ok(v)
}
async fn parse_and_render_at_depth<T: TemplateCallback>(
template: &str,
vars: &HashMap<String, String>,
cb: &T,
opt: &RenderOptions,
depth: usize,
) -> Result<String> {
let mut p = Parser::new(template);
let tokens = p.parse()?;
render(tokens, vars, cb, opt, depth + 1).await
}
pub async fn parse_and_render<T: TemplateCallback>(
template: &str,
vars: &HashMap<String, String>,
cb: &T,
opt: &RenderOptions,
) -> Result<String> {
parse_and_render_at_depth(template, vars, cb, opt, 1).await
}
pub enum RenderErrorBehavior {
Throw,
ReturnEmpty,
}
pub struct RenderOptions {
pub error_behavior: RenderErrorBehavior,
}
impl RenderOptions {
pub fn throw() -> Self {
Self { error_behavior: RenderErrorBehavior::Throw }
}
}
impl RenderErrorBehavior {
pub fn handle(&self, r: Result<String>) -> Result<String> {
match (self, r) {
(_, Ok(v)) => Ok(v),
(RenderErrorBehavior::Throw, Err(e)) => Err(e),
(RenderErrorBehavior::ReturnEmpty, Err(e)) => {
warn!("Error rendering string: {}", e);
Ok("".to_string())
}
}
}
}
pub async fn render<T: TemplateCallback>(
tokens: Tokens,
vars: &HashMap<String, String>,
cb: &T,
opt: &RenderOptions,
mut depth: usize,
) -> Result<String> {
depth += 1;
if depth > MAX_DEPTH {
return opt.error_behavior.handle(Err(RenderStackExceededError));
}
let mut doc_str: Vec<String> = Vec::new();
for t in tokens.tokens {
match t {
Token::Raw { text } => doc_str.push(text),
Token::Tag { val } => {
let val = render_value(val, &vars, cb, opt, depth).await;
doc_str.push(opt.error_behavior.handle(val)?)
}
Token::Eof => {}
}
}
Ok(doc_str.join(""))
}
async fn render_value<T: TemplateCallback>(
val: Val,
vars: &HashMap<String, String>,
cb: &T,
opt: &RenderOptions,
depth: usize,
) -> Result<String> {
let v = match val {
Val::Str { text } => {
let r = Box::pin(parse_and_render_at_depth(&text, vars, cb, opt, depth)).await?;
r.to_string()
}
Val::Var { name } => match vars.get(name.as_str()) {
Some(v) => {
let r = Box::pin(parse_and_render_at_depth(v, vars, cb, opt, depth)).await?;
r.to_string()
}
None => return Err(VariableNotFound(name)),
},
Val::Fn { name, args } => {
let mut resolved_args: HashMap<String, serde_json::Value> = HashMap::new();
for a in args {
let v = match a.value.clone() {
Val::Bool { value } => serde_json::Value::Bool(value),
Val::Null => serde_json::Value::Null,
_ => serde_json::Value::String(
Box::pin(render_value(a.value, vars, cb, opt, depth)).await?,
),
};
resolved_args.insert(a.name, v);
}
let result = cb.run(name.as_str(), resolved_args.clone()).await?;
Box::pin(parse_and_render_at_depth(&result, vars, cb, opt, depth)).await?
}
Val::Bool { value } => value.to_string(),
Val::Null => "".into(),
};
Ok(v)
}
#[cfg(test)]
mod parse_and_render_tests {
use crate::error::Error::{RenderError, RenderStackExceededError, VariableNotFound};
use crate::error::Result;
use crate::renderer::TemplateCallback;
use crate::*;
use std::collections::HashMap;
struct EmptyCB {}
impl TemplateCallback for EmptyCB {
async fn run(
&self,
_fn_name: &str,
_args: HashMap<String, serde_json::Value>,
) -> Result<String> {
todo!()
}
fn transform_arg(
&self,
_fn_name: &str,
_arg_name: &str,
arg_value: &str,
) -> Result<String> {
Ok(arg_value.to_string())
}
}
#[tokio::test]
async fn render_empty() -> Result<()> {
let empty_cb = EmptyCB {};
let template = "";
let vars = HashMap::new();
let result = "";
let opt = RenderOptions { error_behavior: RenderErrorBehavior::Throw };
assert_eq!(parse_and_render(template, &vars, &empty_cb, &opt).await?, result.to_string());
Ok(())
}
#[tokio::test]
async fn render_text_only() -> Result<()> {
let empty_cb = EmptyCB {};
let template = "Hello World!";
let vars = HashMap::new();
let result = "Hello World!";
let opt = RenderOptions { error_behavior: RenderErrorBehavior::Throw };
assert_eq!(parse_and_render(template, &vars, &empty_cb, &opt).await?, result.to_string());
Ok(())
}
#[tokio::test]
async fn render_simple() -> Result<()> {
let empty_cb = EmptyCB {};
let template = "${[ foo ]}";
let vars = HashMap::from([("foo".to_string(), "bar".to_string())]);
let result = "bar";
let opt = RenderOptions { error_behavior: RenderErrorBehavior::Throw };
assert_eq!(parse_and_render(template, &vars, &empty_cb, &opt).await?, result.to_string());
Ok(())
}
#[tokio::test]
async fn render_recursive_var() -> Result<()> {
let empty_cb = EmptyCB {};
let template = "${[ foo ]}";
let mut vars = HashMap::new();
vars.insert("foo".to_string(), "foo: ${[ bar ]}".to_string());
vars.insert("bar".to_string(), "bar: ${[ baz ]}".to_string());
vars.insert("baz".to_string(), "baz".to_string());
let result = "foo: bar: baz";
let opt = RenderOptions { error_behavior: RenderErrorBehavior::Throw };
assert_eq!(parse_and_render(template, &vars, &empty_cb, &opt).await?, result.to_string());
Ok(())
}
#[tokio::test]
async fn render_missing_var() -> Result<()> {
let empty_cb = EmptyCB {};
let template = "${[ foo ]}";
let vars = HashMap::new();
let opt = RenderOptions { error_behavior: RenderErrorBehavior::Throw };
assert_eq!(
parse_and_render(template, &vars, &empty_cb, &opt).await,
Err(VariableNotFound("foo".to_string()))
);
Ok(())
}
#[tokio::test]
async fn render_empty_var() -> Result<()> {
let empty_cb = EmptyCB {};
let template = "${[ foo ]}";
let mut vars = HashMap::new();
vars.insert("foo".to_string(), "".to_string());
let opt = RenderOptions { error_behavior: RenderErrorBehavior::Throw };
assert_eq!(parse_and_render(template, &vars, &empty_cb, &opt).await, Ok("".to_string()));
Ok(())
}
#[tokio::test]
async fn render_self_referencing_var() -> Result<()> {
let empty_cb = EmptyCB {};
let template = "${[ foo ]}";
let mut vars = HashMap::new();
vars.insert("foo".to_string(), "${[ foo ]}".to_string());
let opt = RenderOptions { error_behavior: RenderErrorBehavior::Throw };
assert_eq!(
parse_and_render(template, &vars, &empty_cb, &opt).await,
Err(RenderStackExceededError)
);
Ok(())
}
#[tokio::test]
async fn render_surrounded() -> Result<()> {
let empty_cb = EmptyCB {};
let template = "hello ${[ word ]} world!";
let vars = HashMap::from([("word".to_string(), "cruel".to_string())]);
let result = "hello cruel world!";
let opt = RenderOptions { error_behavior: RenderErrorBehavior::Throw };
assert_eq!(parse_and_render(template, &vars, &empty_cb, &opt).await?, result.to_string());
Ok(())
}
#[tokio::test]
async fn render_valid_fn() -> Result<()> {
let vars = HashMap::new();
let template = r#"${[ say_hello(a='John', b='Kate') ]}"#;
let result = r#"say_hello: 2, Some(String("John")) Some(String("Kate"))"#;
let opt = RenderOptions { error_behavior: RenderErrorBehavior::Throw };
struct CB {}
impl TemplateCallback for CB {
async fn run(
&self,
fn_name: &str,
args: HashMap<String, serde_json::Value>,
) -> Result<String> {
Ok(format!("{fn_name}: {}, {:?} {:?}", args.len(), args.get("a"), args.get("b")))
}
fn transform_arg(
&self,
_fn_name: &str,
_arg_name: &str,
arg_value: &str,
) -> Result<String> {
Ok(arg_value.to_string())
}
}
assert_eq!(parse_and_render(template, &vars, &CB {}, &opt).await?, result);
Ok(())
}
#[tokio::test]
async fn render_fn_arg() -> Result<()> {
let vars = HashMap::new();
let template = r#"${[ upper(foo='bar') ]}"#;
let result = r#""BAR""#;
let opt = RenderOptions { error_behavior: RenderErrorBehavior::Throw };
struct CB {}
impl TemplateCallback for CB {
async fn run(
&self,
fn_name: &str,
args: HashMap<String, serde_json::Value>,
) -> Result<String> {
Ok(match fn_name {
"secret" => "abc".to_string(),
"upper" => args["foo"].to_string().to_uppercase(),
_ => "".to_string(),
})
}
fn transform_arg(
&self,
_fn_name: &str,
_arg_name: &str,
_arg_value: &str,
) -> Result<String> {
todo!()
}
}
assert_eq!(parse_and_render(template, &vars, &CB {}, &opt).await?, result.to_string());
Ok(())
}
#[tokio::test]
async fn render_fn_b64_arg_template() -> Result<()> {
let mut vars = HashMap::new();
vars.insert("foo".to_string(), "bar".to_string());
let template = r#"${[ upper(foo=b64'Zm9vICdiYXInIGJheg') ]}"#;
let result = r#""FOO 'BAR' BAZ""#;
let opt = RenderOptions { error_behavior: RenderErrorBehavior::Throw };
struct CB {}
impl TemplateCallback for CB {
async fn run(
&self,
fn_name: &str,
args: HashMap<String, serde_json::Value>,
) -> Result<String> {
Ok(match fn_name {
"upper" => args["foo"].to_string().to_uppercase(),
_ => "".to_string(),
})
}
fn transform_arg(
&self,
_fn_name: &str,
_arg_name: &str,
_arg_value: &str,
) -> Result<String> {
todo!()
}
}
assert_eq!(parse_and_render(template, &vars, &CB {}, &opt).await?, result.to_string());
Ok(())
}
#[tokio::test]
async fn render_fn_arg_template() -> Result<()> {
let mut vars = HashMap::new();
vars.insert("foo".to_string(), "bar".to_string());
let template = r#"${[ upper(foo='${[ foo ]}') ]}"#;
let result = r#""BAR""#;
let opt = RenderOptions { error_behavior: RenderErrorBehavior::Throw };
struct CB {}
impl TemplateCallback for CB {
async fn run(
&self,
fn_name: &str,
args: HashMap<String, serde_json::Value>,
) -> Result<String> {
Ok(match fn_name {
"secret" => "abc".to_string(),
"upper" => args["foo"].to_string().to_uppercase(),
_ => "".to_string(),
})
}
fn transform_arg(
&self,
_fn_name: &str,
_arg_name: &str,
_arg_value: &str,
) -> Result<String> {
todo!()
}
}
assert_eq!(parse_and_render(template, &vars, &CB {}, &opt).await?, result.to_string());
Ok(())
}
#[tokio::test]
async fn render_fn_return_template() -> Result<()> {
let mut vars = HashMap::new();
vars.insert("foo".to_string(), "bar".to_string());
let template = r#"${[ no_op(inner='${[ foo ]}') ]}"#;
let result = r#""bar""#;
let opt = RenderOptions { error_behavior: RenderErrorBehavior::Throw };
struct CB {}
impl TemplateCallback for CB {
async fn run(
&self,
fn_name: &str,
args: HashMap<String, serde_json::Value>,
) -> Result<String> {
Ok(match fn_name {
"no_op" => args["inner"].to_string(),
_ => "".to_string(),
})
}
fn transform_arg(
&self,
_fn_name: &str,
_arg_name: &str,
_arg_value: &str,
) -> Result<String> {
todo!()
}
}
assert_eq!(parse_and_render(template, &vars, &CB {}, &opt).await?, result.to_string());
Ok(())
}
#[tokio::test]
async fn render_nested_fn() -> Result<()> {
let vars = HashMap::new();
let template = r#"${[ upper(foo=secret()) ]}"#;
let result = r#""ABC""#;
let opt = RenderOptions { error_behavior: RenderErrorBehavior::Throw };
struct CB {}
impl TemplateCallback for CB {
async fn run(
&self,
fn_name: &str,
args: HashMap<String, serde_json::Value>,
) -> Result<String> {
Ok(match fn_name {
"secret" => "abc".to_string(),
"upper" => args["foo"].to_string().to_uppercase(),
_ => "".to_string(),
})
}
fn transform_arg(
&self,
_fn_name: &str,
_arg_name: &str,
arg_value: &str,
) -> Result<String> {
Ok(arg_value.to_string())
}
}
assert_eq!(parse_and_render(template, &vars, &CB {}, &opt).await?, result.to_string());
Ok(())
}
#[tokio::test]
async fn render_fn_err() -> Result<()> {
let vars = HashMap::new();
let template = r#"hello ${[ error() ]}"#;
let opt = RenderOptions { error_behavior: RenderErrorBehavior::Throw };
struct CB {}
impl TemplateCallback for CB {
async fn run(
&self,
_fn_name: &str,
_args: HashMap<String, serde_json::Value>,
) -> Result<String> {
Err(RenderError("Failed to do it!".to_string()))
}
fn transform_arg(
&self,
_fn_name: &str,
_arg_name: &str,
arg_value: &str,
) -> Result<String> {
Ok(arg_value.to_string())
}
}
assert_eq!(
parse_and_render(template, &vars, &CB {}, &opt).await,
Err(RenderError("Failed to do it!".to_string()))
);
Ok(())
}
}
#[cfg(test)]
mod render_json_value_raw_tests {
use crate::error::Result;
use crate::{
RenderErrorBehavior, RenderOptions, TemplateCallback, parse_and_render,
render_json_value_raw,
};
use serde_json::json;
use std::collections::HashMap;
struct EmptyCB {}
impl TemplateCallback for EmptyCB {
async fn run(
&self,
_fn_name: &str,
_args: HashMap<String, serde_json::Value>,
) -> Result<String> {
todo!()
}
fn transform_arg(
&self,
_fn_name: &str,
_arg_name: &str,
arg_value: &str,
) -> Result<String> {
Ok(arg_value.to_string())
}
}
#[tokio::test]
async fn render_json_value_string() -> Result<()> {
let v = json!("${[a]}");
let mut vars = HashMap::new();
vars.insert("a".to_string(), "aaa".to_string());
let opt = RenderOptions { error_behavior: RenderErrorBehavior::Throw };
assert_eq!(render_json_value_raw(v, &vars, &EmptyCB {}, &opt).await?, json!("aaa"));
Ok(())
}
#[tokio::test]
async fn render_json_value_array() -> Result<()> {
let v = json!(["${[a]}", "${[a]}"]);
let mut vars = HashMap::new();
vars.insert("a".to_string(), "aaa".to_string());
let opt = RenderOptions { error_behavior: RenderErrorBehavior::Throw };
let result = render_json_value_raw(v, &vars, &EmptyCB {}, &opt).await?;
assert_eq!(result, json!(["aaa", "aaa"]));
Ok(())
}
#[tokio::test]
async fn render_json_value_object() -> Result<()> {
let v = json!({"${[a]}": "${[a]}"});
let mut vars = HashMap::new();
vars.insert("a".to_string(), "aaa".to_string());
let opt = RenderOptions { error_behavior: RenderErrorBehavior::Throw };
let result = render_json_value_raw(v, &vars, &EmptyCB {}, &opt).await?;
assert_eq!(result, json!({"aaa": "aaa"}));
Ok(())
}
#[tokio::test]
async fn render_json_value_nested() -> Result<()> {
let v = json!([
123,
{"${[a]}": "${[a]}"},
null,
"${[a]}",
false,
{"x": ["${[a]}"]}
]);
let mut vars = HashMap::new();
vars.insert("a".to_string(), "aaa".to_string());
let opt = RenderOptions { error_behavior: RenderErrorBehavior::Throw };
let result = render_json_value_raw(v, &vars, &EmptyCB {}, &opt).await?;
assert_eq!(
result,
json!([
123,
{"aaa": "aaa"},
null,
"aaa",
false,
{"x": ["aaa"]}
])
);
Ok(())
}
#[tokio::test]
async fn render_opt_return_empty() -> Result<()> {
let vars = HashMap::new();
let opt = RenderOptions { error_behavior: RenderErrorBehavior::ReturnEmpty };
let result = parse_and_render("DNE: ${[hello]}", &vars, &EmptyCB {}, &opt).await?;
assert_eq!(result, "DNE: ".to_string());
Ok(())
}
}

View File

@@ -0,0 +1,22 @@
use crate::error::Result;
use crate::{Parser, escape};
use wasm_bindgen::JsValue;
use wasm_bindgen::prelude::wasm_bindgen;
#[wasm_bindgen]
pub fn parse_template(template: &str) -> Result<JsValue> {
let tokens = Parser::new(template).parse()?;
Ok(serde_wasm_bindgen::to_value(&tokens).unwrap())
}
#[wasm_bindgen]
pub fn escape_template(template: &str) -> Result<JsValue> {
let escaped = escape::escape_template(template);
Ok(serde_wasm_bindgen::to_value(&escaped).unwrap())
}
#[wasm_bindgen]
pub fn unescape_template(template: &str) -> Result<JsValue> {
let escaped = escape::unescape_template(template);
Ok(serde_wasm_bindgen::to_value(&escaped).unwrap())
}