mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-04-25 10:18:31 +02:00
Decouple core Yaak logic from Tauri (#354)
This commit is contained in:
22
crates/yaak-templates/Cargo.toml
Normal file
22
crates/yaak-templates/Cargo.toml
Normal 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 }
|
||||
9
crates/yaak-templates/bindings/parser.ts
generated
Normal file
9
crates/yaak-templates/bindings/parser.ts
generated
Normal 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" };
|
||||
15
crates/yaak-templates/index.ts
Normal file
15
crates/yaak-templates/index.ts
Normal 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;
|
||||
}
|
||||
15
crates/yaak-templates/package.json
Normal file
15
crates/yaak-templates/package.json
Normal 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
17
crates/yaak-templates/pkg/package.json
generated
Normal 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
5
crates/yaak-templates/pkg/yaak_templates.d.ts
generated
vendored
Normal 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;
|
||||
5
crates/yaak-templates/pkg/yaak_templates.js
generated
Normal file
5
crates/yaak-templates/pkg/yaak_templates.js
generated
Normal 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();
|
||||
251
crates/yaak-templates/pkg/yaak_templates_bg.js
generated
Normal file
251
crates/yaak-templates/pkg/yaak_templates_bg.js
generated
Normal 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));
|
||||
};
|
||||
|
||||
BIN
crates/yaak-templates/pkg/yaak_templates_bg.wasm
generated
Normal file
BIN
crates/yaak-templates/pkg/yaak_templates_bg.wasm
generated
Normal file
Binary file not shown.
11
crates/yaak-templates/pkg/yaak_templates_bg.wasm.d.ts
generated
vendored
Normal file
11
crates/yaak-templates/pkg/yaak_templates_bg.wasm.d.ts
generated
vendored
Normal 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;
|
||||
32
crates/yaak-templates/src/error.rs
Normal file
32
crates/yaak-templates/src/error.rs
Normal 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>;
|
||||
166
crates/yaak-templates/src/escape.rs
Normal file
166
crates/yaak-templates/src/escape.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
304
crates/yaak-templates/src/format_json.rs
Normal file
304
crates/yaak-templates/src/format_json.rs
Normal 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
9
crates/yaak-templates/src/lib.rs
Normal file
9
crates/yaak-templates/src/lib.rs
Normal 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::*;
|
||||
904
crates/yaak-templates/src/parser.rs
Normal file
904
crates/yaak-templates/src/parser.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
639
crates/yaak-templates/src/renderer.rs
Normal file
639
crates/yaak-templates/src/renderer.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
22
crates/yaak-templates/src/wasm.rs
Normal file
22
crates/yaak-templates/src/wasm.rs
Normal 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())
|
||||
}
|
||||
Reference in New Issue
Block a user