Skip to content

crux-03/weaver_lang

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

8 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

The Weaver Language

A template language for procedural content generation in Rust. Parse text with embedded expressions, control flow, and host-defined callables, then evaluate it into a final string.

Hello, {{global:name}}! You have {{global:hp}} HP.
{# if {{global:hp}} < 20 #}
You're badly wounded.
{# elif {{global:hp}} < 50 #}
You've seen better days.
{# else #}
You're in fighting shape.
{# endif #}

weaver-lang separates the language (parsing and evaluation) from the host (state, triggers, documents) through a trait boundary. The library has no opinion about where your data lives — you provide it through an EvalContext implementation.

Quick start

use weaver_lang::{render, SimpleContext, Registry};

let mut ctx = SimpleContext::new();
ctx.set("global", "name", "Alice");

let registry = Registry::new();
let output = render("Hello, {{global:name}}!", &mut ctx, &registry).unwrap();
assert_eq!(output, "Hello, Alice!");

Compiled templates

Parse once, evaluate many times:

use weaver_lang::{CompiledTemplate, SimpleContext, Registry};

let template = CompiledTemplate::compile("HP: {{global:hp}}").unwrap();
let registry = Registry::new();
let mut ctx = SimpleContext::new();

ctx.set("global", "hp", 100i64);
assert_eq!(template.evaluate(&mut ctx, &registry).unwrap(), "HP: 100");

ctx.set("global", "hp", 42i64);
assert_eq!(template.evaluate(&mut ctx, &registry).unwrap(), "HP: 42");

Syntax reference

Overview

type syntax
variables {{namespace:value}}
processors @[namespace.name(foo: value1, bar: value2)]
commands $[name(foo, bar)]
triggers <trigger id="some_id">
documents [[some_id]]
if/else {# if foo == bar #} baz {# endif #}
foreach {# foreach foo in bar #} - {{foo}} {# endforeach #}

Variables

{{scope:name}}

Look up a variable by scope and name. global and local are conventional, but hosts can define any scope.

Processors — @[namespace.name(key: value)]

Pure computations with named properties. No access to evaluation state.

@[math.add(a: 1, b: 2)]
@[core.weaver.rng(min: 1, max: 100)]

Commands — $[name(arg1, arg2)]

Stateful operations with positional arguments. Can read/write variables through the evaluation context.

$[set_var("global:name", "Alice")]
$[greet("world")]

When a command appears alone on a line, the entire line is consumed — no blank line is left in the output:

Line before
$[set_var("global:x", "val")]
Line after

Evaluates to Line before\nLine after.

Triggers and documents

<trigger id="dark_forest">     // Activate another entry, splice its output
[[LORE_INTRO]]                 // Import a reusable content block

Both are expressions — they can appear in arrays, processor arguments, conditions, etc.

Control flow

{# if {{global:hp}} < 20 #}
  Critical condition!
{# elif {{global:hp}} < 50 #}
  Wounded.
{# else #}
  Healthy.
{# endif #}

{# foreach item in ["sword", "shield", "potion"] #}
  - {{item}}
{# endforeach #}

Control flow tags on their own lines don't produce blank lines in the output.

Expressions and operators

Expressions appear in conditions, arguments, and inline. Types are preserved internally (string, number, bool, array, none) and coerced to strings only at the template output level.

Comparison: ==, !=, <, >, <=, >= Logical: &&, ||, ! Arithmetic: +, -, *, /

+ concatenates when either operand is a string. Division by zero returns an error.

Truthiness: empty string, 0, false, empty array, and none are falsy. Everything else is truthy.

Precedence (highest to lowest): unary (!, -), arithmetic (*, /, +, -), comparison (==, !=, <, >, <=, >=), logical (&&, ||). Parentheses override precedence.

Registering processors and commands

Closures

use weaver_lang::{Registry, ClosureCommand, ClosureProcessor, Value};

let mut registry = Registry::new();

registry.register_processor(ClosureProcessor::new("math", "add", |props| {
    let a = props.get("a").and_then(|v| v.as_number()).unwrap_or(0.0);
    let b = props.get("b").and_then(|v| v.as_number()).unwrap_or(0.0);
    Ok(Value::Number(a + b))
}));

registry.register_command(ClosureCommand::new("echo", |args| {
    Ok(args.into_iter().next())
}));

Proc macros

The weaver-macros crate generates trait implementations from function signatures with automatic type validation:

use weaver_lang::{Value, EvalError};
use weaver_macros::weaver_processor;

#[weaver_processor(namespace = "text", name = "repeat")]
fn repeat_text(text: String, count: f64) -> Result<Value, EvalError> {
    Ok(Value::String(text.repeat(count as usize)))
}

// Generates `RepeatTextProcessor` struct implementing `WeaverProcessor`.
// registry.register_processor(RepeatTextProcessor);

Commands can opt into context access by naming a parameter ctx:

use weaver_lang::{Value, EvalError, EvalContext};
use weaver_macros::weaver_command;

#[weaver_command(name = "set_var")]
fn set_var(key: String, value: Value, ctx: &mut dyn EvalContext) -> Result<Option<Value>, EvalError> {
    if let Some(pos) = key.find(':') {
        ctx.set_variable(&key[..pos], &key[pos + 1..], value)?;
    }
    Ok(None)
}

// Generates `SetVarCommand` struct implementing `WeaverCommand`.

Supported parameter types: Value (any), String, f64, bool, Vec<Value>.

Trait implementations

For full control, implement WeaverCommand or WeaverProcessor directly:

use weaver_lang::{Value, EvalError, EvalContext, Registry};
use weaver_lang::registry::{WeaverCommand, CommandSignature};

struct MyCommand;

impl WeaverCommand for MyCommand {
    fn call(
        &self,
        args: Vec<Value>,
        ctx: &mut dyn EvalContext,
        _registry: &Registry,
    ) -> Result<Option<Value>, EvalError> {
        // Full access to args, context, and registry
        Ok(None)
    }

    fn signature(&self) -> CommandSignature {
        CommandSignature {
            name: "my_command".to_string(),
            params: Vec::new(),
        }
    }
}

Implementing EvalContext

SimpleContext works for testing. For production, implement the EvalContext trait to connect weaver-lang to your application's state:

use weaver_lang::{EvalContext, EvalError, Value, Registry};

struct GameContext { /* your state */ }

impl EvalContext for GameContext {
    fn resolve_variable(&self, scope: &str, name: &str) -> Result<Option<Value>, EvalError> {
        // Look up variables from your storage.
        // Return Ok(None) for undefined variables.
        todo!()
    }

    fn set_variable(&mut self, scope: &str, name: &str, value: Value) -> Result<(), EvalError> {
        // Persist variable changes
        todo!()
    }

    fn fire_trigger(&mut self, entry_id: &str, registry: &Registry) -> Result<String, EvalError> {
        // Look up the target entry, evaluate it, return the output.
        // You are responsible for cycle detection and depth limiting.
        todo!()
    }

    fn resolve_document(&mut self, document_id: &str, registry: &Registry) -> Result<String, EvalError> {
        // Return document content (raw or pre-evaluated)
        todo!()
    }
}

The evaluator manages temporary scopes internally (foreach bindings). Only named scope operations like "global" and "local" reach the host.

Evaluation options

Configure resource limits, cancellation, and lenient mode:

use weaver_lang::{render_with_options, EvalOptions, SimpleContext, Registry};
use std::sync::Arc;
use std::sync::atomic::AtomicBool;

let mut ctx = SimpleContext::new();
let registry = Registry::new();

let cancel = Arc::new(AtomicBool::new(false));

let opts = EvalOptions::new()
    .max_node_evaluations(10_000)   // cap AST node evaluations
    .max_iterations(1_000)          // cap total loop iterations
    .cancellation_token(cancel)     // abort from another thread
    .lenient(true);                 // undefined vars render as raw syntax

let result = render_with_options(
    "Hello, {{global:missing}}!",
    &mut ctx,
    &registry,
    opts,
).unwrap();
assert_eq!(result, "Hello, {{global:missing}}!");

Error reporting

Parse errors carry source spans. Use format_with_source for diagnostics:

Error: undefined variable: global:player_name
 --> Dark Forest:12:6
  |
 12 |  {# if {{global:player_name}} #}
    |        ^^^^^^^^^^^^^^^^^^^^^^^
  = hint: did you mean to define this variable first?

Eval errors support error chaining for host-originated failures:

use weaver_lang::EvalError;

let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file missing");
let err = EvalError::host_error("failed to load entry").with_source(io_err);
// The full error chain is preserved via std::error::Error::source()

Known limitations

  • All numbers are f64. Large integers above 2^53 lose precision.
  • No assignment syntax in the language. Variable mutation goes through commands which hosts need to define.
  • Document evaluation depends on the host's resolve_document implementation.

Dependencies

License

MIT

About

A template language and evaluation engine for procedural content generation in Rust

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages