expr

package
v0.9.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Mar 18, 2026 License: MIT Imports: 6 Imported by: 0

Documentation

Overview

Package expr implements the expression language used in htmlc templates.

Package expr is the expression evaluator for htmlc templates.

Tutorial

The three most common entry points for Go callers are Eval, Compile, and CollectIdentifiers.

## Eval — one-shot expression evaluation

Eval parses and evaluates an expression in a single call. Use it when the expression string changes at runtime or is only evaluated once.

result, err := expr.Eval("price * qty + 1", map[string]any{
    "price": float64(10),
    "qty":   float64(3),
})
// result == float64(31)

## Compile + Eval — pre-compile for repeated evaluation

Compile parses the expression once and returns an *Expr that can be re-evaluated against many different scopes without re-parsing.

e, err := expr.Compile("user.name + ' (' + user.role + ')'")
if err != nil { // syntax error
}

// Re-use the compiled expression across many scopes:
for _, user := range users {
    val, err := e.Eval(map[string]any{"user": user})
    _ = val
}

## CollectIdentifiers — static analysis

CollectIdentifiers returns the sorted, deduplicated list of top-level identifiers referenced by an expression. It does not evaluate the expression.

names, err := expr.CollectIdentifiers("user.name + extra")
// names == []string{"extra", "user"}

Used by Component.Props() (in the parent htmlc package) to discover which scope keys a template depends on.

## RegisterBuiltin — custom functions

RegisterBuiltin adds a function to the global built-in table so it is available by name in every expression. Call it once at program startup.

expr.RegisterBuiltin("upper", func(args ...any) (any, error) {
    if len(args) != 1 {
        return nil, fmt.Errorf("upper: want 1 arg")
    }
    s, _ := args[0].(string)
    return strings.ToUpper(s), nil
})
// Now usable in any expression: upper(name)

Note: RegisterBuiltin modifies global state; it is not safe to call concurrently with expression evaluation.

## IsTruthy — truthiness outside templates

IsTruthy reports whether a Go value is truthy by the same rules used for v-if, v-show, and boolean operators inside templates.

expr.IsTruthy(0)              // false
expr.IsTruthy("")             // false
expr.IsTruthy(expr.Undefined) // false
expr.IsTruthy(false)          // false
expr.IsTruthy(1)              // true
expr.IsTruthy("hello")        // true

Expression Language Reference

This document describes the syntax and semantics of the expr expression language. It is a declarative, side-effect-free subset of JavaScript expression syntax evaluated against a Go scope map.

Literals

42          integer (stored as float64)
3.14        float
.5          leading-dot float
1e6         scientific notation (float)
"hello"     double-quoted string
'hello'     single-quoted string
true        boolean true
false       boolean false
null        null value (Go nil)
undefined   undefined value (Go UndefinedValue{})

Supported string escape sequences: \n \t \r \\ \' \". All numeric literals are represented internally as float64.

Identifiers

An identifier is a sequence of letters, digits, underscores, or dollar signs that does not begin with a digit. Names are resolved in the following order:

  1. The caller-supplied scope map (map[string]any).
  2. The built-in function table (registered via RegisterBuiltin).
  3. If absent from both, the name evaluates to UndefinedValue.

Scope map keys shadow built-in names.

Operator Precedence

The table lists all operators from highest to lowest precedence. Operators on the same row have equal precedence and are left-associative unless noted.

Precedence  Operator(s)         Associativity  Category
──────────  ──────────────────  ─────────────  ─────────────────────────────
15          (unary) ! - + ~ typeof void        right (unary prefix)
12          **                  right          exponentiation
11          *  /  %             left           multiplicative
10          +  -                left           additive
 9          <<  >>  >>>         left           bitwise shift
 8          <  <=  >  >=  in  instanceof       left           relational
 7          ==  !=  ===  !==    left           equality
 6          &                   left           bitwise AND
 5          ^                   left           bitwise XOR
 4          |                   left           bitwise OR
 3          &&                  left           logical AND
 2          ||                  left           logical OR
 1          ??                  left           nullish coalescing
 0          ?:                  right          ternary conditional

Grouping with parentheses overrides precedence.

## Unary Operators

!x      logical NOT; returns bool
-x      numeric negation; converts x to float64
+x      numeric identity; converts x to float64
~x      bitwise NOT; converts x to int32, returns float64
typeof x  returns a JS-compatible type string (see Type Coercion)
void x    evaluates x, returns UndefinedValue

## Binary Operators

**        exponentiation (right-associative)
*  /  %   multiplication, division, modulo (float64)
+         addition or string concatenation (see Type Coercion)
-         subtraction
<<  >>    signed bitwise shift; operands converted to int32, result is float64
>>>       unsigned right shift; operands converted to uint32, result is float64
<  <=  >  >=   relational; string–string uses lexicographic order, otherwise float64
in        key membership: "k" in obj (map or struct); returns bool
instanceof  always returns false (Go objects have no prototype chain)
==  !=    abstract (loose) equality/inequality (see Type Coercion)
===  !==  strict equality/inequality (no type coercion)
&  ^  |   bitwise AND, XOR, OR; operands converted to int32, result is float64
&&        logical AND; short-circuits; returns the deciding operand value
||        logical OR; short-circuits; returns the deciding operand value
??        nullish coalescing; returns right operand when left is null or undefined

## Ternary Operator

condition ? consequent : alternate

Evaluates condition; if truthy evaluates and returns consequent, otherwise evaluates and returns alternate. Right-associative. a ? b : c ? d : e is parsed as a ? b : (c ? d : e).

Member Access

Dot notation accesses a named field or map key:

obj.field
obj.nested.field

Bracket notation accesses a computed key or numeric index:

obj["key"]
arr[0]
arr[i]

For slices and arrays the special property "length" returns the element count as float64. Struct fields are matched by exported name first, then by the "json" struct tag (first tag segment). If the key is absent from a map or struct, the result is UndefinedValue.

Accessing a member of null or UndefinedValue is a runtime error.

Function Calls

callee(arg1, arg2)

The callee must evaluate to a Go value of type func(...any) (any, error). Arguments are evaluated left-to-right before the function is called. Scope values of the correct function type can be called directly.

Built-in Functions

The engine ships with no pre-registered built-in functions. Callers add custom functions via RegisterBuiltin:

expr.RegisterBuiltin("upper", func(args ...any) (any, error) {
    if len(args) != 1 {
        return nil, fmt.Errorf("upper: want 1 arg")
    }
    s, _ := args[0].(string)
    return strings.ToUpper(s), nil
})

Functions registered this way are available in all expressions by name. Scope map keys shadow built-in names.

For the common case of measuring collection sizes, use the built-in .length member property instead of a function call. It is available on strings, slices, arrays, and maps via member-access syntax and requires no registration:

items.length     // number of elements in a slice or array
name.length      // number of bytes in a string
obj.length       // number of entries in a map

Type Coercion

All numeric values are stored as float64. Go integer types (int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64) are normalised to float64 for all comparison and arithmetic operations.

The + operator performs string concatenation when either operand is a string; otherwise both operands are converted to float64 and added numerically.

Relational operators (<, <=, >, >=) use lexicographic string comparison when both operands are strings; otherwise both are converted to float64.

Abstract equality (==) follows JavaScript abstract equality rules:

  • null == undefined and undefined == null are true.
  • null or undefined compared to any other type is false.
  • bool operands are converted to float64 (true→1, false→0) before comparison.
  • A numeric operand compared to a string converts the string to float64.
  • All Go numeric types are treated as equivalent to float64 for ==.

Strict equality (===) does not coerce types; it returns true only when both operands are the same type (or both numeric Go types) and equal in value. NaN !== NaN.

Truthiness (used by !, &&, ||, ternary) follows JavaScript rules:

  • false, 0, NaN, "", null, and UndefinedValue are falsy.
  • All other values are truthy.

typeof returns:

  • "undefined" for UndefinedValue
  • "boolean" for bool
  • "number" for float64
  • "string" for string
  • "function" for func(...any)(any,error)
  • "object" for nil (mirrors typeof null in JavaScript)
  • "object" for all other types

UndefinedValue

UndefinedValue is a Go struct type (type UndefinedValue struct{}) used as a sentinel for absent values. It is distinct from Go nil and from the null literal:

null       → Go nil         (NullLit in AST, nil in eval)
undefined  → UndefinedValue (UndefinedLit in AST, Undefined in eval)

An identifier that is absent from both the scope map and the built-ins evaluates to UndefinedValue, not to nil. This distinction is observable via === and typeof.

Array and Object Literals

[1, 'two', item]                array literal; evaluates to []any
{key: val, "x": 1}              object literal; evaluates to map[string]any

Object keys must be identifiers or string literals.

Unsupported Constructs

The following constructs look like JavaScript but are not supported. The parser or evaluator will return an error rather than silently ignoring them.

x = y               assignment (any form)
x += y              compound assignment
x++  x--            increment/decrement
`template ${x}`     template literals (backtick strings)
...spread           spread operator
(x) => x            arrow functions
function f() {}     function declarations
new Foo()           object instantiation (new keyword)
delete obj.key      property deletion
class Foo {}        class declarations

Package expr provides the evaluator that executes htmlc template expressions.

Explanation: Design and Rationale

This section answers _why_ the expr package is built the way it is. For syntax tables and API details, see the reference in doc.go.

## Purpose and Scope

The expr package exists because htmlc templates need to evaluate Vue.js-style attribute bindings (`:attr="expr"`, `{{ interpolation }}`, `v-if="expr"`) on the server side, in Go. It intentionally implements only the subset of JavaScript expression semantics required by those bindings — nothing more — because a full JS engine would introduce enormous complexity and dependency weight that would never be justified by the narrow use case. Constructs that have no place in a declarative template binding (assignment, closures, `new`, `class`) are excluded by design, not by accident. This scoping decision is what keeps the evaluator small enough to audit and reason about.

## Pipeline Architecture

Expression evaluation is split into three sequential stages: the Lexer turns source text into a flat token stream, the Parser turns that stream into an AST, and Eval walks the AST against a scope map to produce a value. The stages are separate because different callers need different amounts of the pipeline. CollectIdentifiers, for example, only needs the Lexer and Parser — it walks the AST to find referenced names without evaluating anything. If the pipeline were fused into a single function, that reuse would be impossible. Separate stages also make testing tractable: each stage can be exercised in isolation, so a failing test immediately narrows the fault to one layer rather than requiring the entire pipeline to be traced.

## JavaScript-like Semantics in Go

The evaluator deliberately mirrors JavaScript semantics rather than inventing its own, because the expressions being evaluated are authored by developers who expect JS behaviour. Surprising them with different rules would introduce subtle, hard-to-debug discrepancies between client-side (Vue) and server-side (htmlc) rendering. All numeric literals are stored as float64 for this reason: JavaScript has only one numeric type, so matching that model means numeric expressions produce identical results on both sides. Similarly, the truthy/falsy rules (empty string and 0 are falsy; any non-zero number or non-empty string is truthy) follow JavaScript exactly, because Go's own truthiness rules differ and applying them would silently break `v-if` conditions that work correctly in the browser.

## Scope as map[string]any

The evaluator receives the template's data as a plain `map[string]any` rather than a typed struct or an interface, because the template engine constructs scopes dynamically — component props, loop variables, and slot data are all assembled at runtime from heterogeneous sources. A typed struct would require code generation or reflection-heavy wrappers, adding complexity without meaningful benefit inside a package whose only consumers are other parts of htmlc. The trade-off is that compile-time checking of scope keys is impossible; a misspelled variable name in a template silently evaluates to UndefinedValue. That cost is accepted because it matches how Vue templates themselves behave, and because the primary defence against misspellings is the template compiler's static analysis (CollectIdentifiers), not the evaluator.

## UndefinedValue vs nil

Two absent-value sentinels exist because null and undefined have distinct semantics in JavaScript, and collapsing them onto a single Go nil would lose information that matters for correctness. `nil` in the evaluator represents an intentionally-null value — the result of a `null` literal or a JSON null field — whereas `UndefinedValue` represents a missing key: an identifier that was not found in the scope map or the built-ins. The distinction is observable via `===` and `typeof`, and it matters for truthiness: both are falsy, but `null == undefined` is true under abstract equality while `null === undefined` is false, matching the JavaScript specification. Collapsing them would make it impossible to distinguish a scope variable explicitly set to null from a variable that was never provided at all, which would break expressions like `val ?? "default"`.

## Why a Subset, Not Full JS

Restricting the evaluator to side-effect-free expressions — no assignment, no increment, no closures, no `new` — is what makes server-side rendering safe and predictable. Because the evaluator is stateless, the same expression evaluated twice against the same scope always produces the same result. This property is essential for server-side rendering, where a template may be evaluated concurrently across many requests; if expressions could mutate scope or external state, race conditions and non-deterministic output would follow. The constraint is enforced at parse time: the parser rejects assignment operators and other stateful constructs before evaluation begins, so there is no way to write a template expression that silently modifies shared state.

Package expr provides the lexer that tokenizes htmlc template expressions.

Package expr provides the parser that builds an AST from htmlc expression tokens.

Index

Examples

Constants

This section is empty.

Variables

View Source
var Undefined = UndefinedValue{}

Undefined is the Go representation of the JavaScript `undefined` value.

Functions

func CollectIdentifiers

func CollectIdentifiers(src string) ([]string, error)

CollectIdentifiers compiles src and returns the deduplicated set of identifier names referenced in the expression.

Rules:

  • Identifier → collect the name.
  • MemberExpr with Computed=false (dot notation) → walk Object only.
  • MemberExpr with Computed=true (bracket notation) → walk Object and Property.
  • UnaryExpr → walk Operand.
  • BinaryExpr → walk Left and Right.
  • TernaryExpr → walk Condition, Consequent, and Alternate.
  • CallExpr → walk Callee and all Args.
  • ArrayLit → walk all Elements.
  • ObjectLit → walk all property Value nodes.
  • Literal nodes → skip.
Example

To discover which scope keys an expression reads, use CollectIdentifiers for prop discovery.

package main

import (
	"fmt"
	"sort"

	"github.com/dhamidi/htmlc/expr"
)

func main() {
	ids, _ := expr.CollectIdentifiers("user.name + suffix")
	sort.Strings(ids)
	fmt.Println(ids)
}
Output:
[suffix user]

func Eval

func Eval(src string, scope map[string]any) (any, error)

Eval is a convenience wrapper: it compiles src and evaluates it against scope.

func IsTruthy

func IsTruthy(v any) bool

IsTruthy returns the JS-style truthiness of a value.

func RegisterBuiltin

func RegisterBuiltin(name string, fn func(...any) (any, error))

RegisterBuiltin registers a custom function under name so that it is callable in all subsequent Eval calls without being passed in the scope.

Types

type ArrayLit

type ArrayLit struct {
	Elements []Node
}

ArrayLit represents an array literal, e.g. [1, 'two', item].

type BinaryExpr

type BinaryExpr struct {
	Op    string
	Left  Node
	Right Node
}

BinaryExpr represents a binary infix expression. Op is one of the operators from §1.3: "**", "*", "/", "%", "+", "-", "<<", ">>", ">>>", "<", "<=", ">", ">=", "in", "instanceof", "==", "!=", "===", "!==", "&", "^", "|", "&&", "||", "??".

type BoolLit

type BoolLit struct {
	Value bool
}

BoolLit represents the boolean literals true and false.

type CallExpr

type CallExpr struct {
	Callee Node
	Args   []Node
}

CallExpr represents a function call expression: callee(args...).

type Expr

type Expr interface {
	Eval(scope map[string]any) (any, error)
}

Expr is a compiled expression ready for repeated evaluation.

func Compile

func Compile(src string) (Expr, error)

Compile tokenizes src, parses it, and returns a compiled Expr ready for evaluation. It returns a descriptive error on any lexical or syntax error.

func Parse

func Parse(src string) (Expr, error)

Parse tokenizes src, parses it, and returns a compiled Expr ready for evaluation. It returns a descriptive error on any lexical or syntax error. Parse is an alias for Compile.

Example (FilterWithLen)

To safely read the first element of a list, guard the access with .length.

package main

import (
	"fmt"

	"github.com/dhamidi/htmlc/expr"
)

func main() {
	scope := map[string]any{"items": []any{"first", "second"}}
	result, _ := expr.Eval(`items.length > 0 ? items[0] : null`, scope)
	fmt.Println(result)
}
Output:
first
Example (LogicalShortCircuit)

To provide a fallback when a variable is empty or absent, use || with a default string.

package main

import (
	"fmt"

	"github.com/dhamidi/htmlc/expr"
)

func main() {
	scope := map[string]any{"title": ""}
	result, _ := expr.Eval(`title || "Untitled"`, scope)
	fmt.Println(result)
}
Output:
Untitled
Example (MemberAccess)

To look up nested data, evaluate a dot-chained expression against a nested Go map.

package main

import (
	"fmt"

	"github.com/dhamidi/htmlc/expr"
)

func main() {
	scope := map[string]any{
		"user": map[string]any{
			"address": map[string]any{
				"city": "Berlin",
			},
		},
	}
	result, _ := expr.Eval("user.address.city", scope)
	fmt.Println(result)
}
Output:
Berlin
Example (Ternary)

To apply conditional styling, use a ternary expression to select a CSS class.

package main

import (
	"fmt"

	"github.com/dhamidi/htmlc/expr"
)

func main() {
	scope := map[string]any{"active": true}
	result, _ := expr.Eval(`active ? "btn-active" : "btn"`, scope)
	fmt.Println(result)
}
Output:
btn-active
Example (UndefinedKey)

To detect a missing map key, compare the result against expr.Undefined rather than nil.

package main

import (
	"fmt"

	"github.com/dhamidi/htmlc/expr"
)

func main() {
	scope := map[string]any{"user": map[string]any{"name": "Alice"}}
	result, _ := expr.Eval("user.age", scope)
	fmt.Println(result == expr.Undefined)
	fmt.Println(expr.IsTruthy(result))
}
Output:
true
false

type Identifier

type Identifier struct {
	Name string
}

Identifier represents a variable reference resolved in the evaluation scope.

type Lexer

type Lexer struct {
	// contains filtered or unexported fields
}

Lexer tokenizes an expression string.

func NewLexer

func NewLexer(src string) *Lexer

NewLexer creates a Lexer for the given source string.

func (*Lexer) Next

func (l *Lexer) Next() Token

Next scans and returns the next token. The token is also appended to the internal slice so callers can retrieve all tokens via Tokens().

func (*Lexer) Tokens

func (l *Lexer) Tokens() []Token

Tokens returns all tokens produced so far (including the current one).

type MemberExpr

type MemberExpr struct {
	Object   Node
	Property Node
	Computed bool // true for obj[expr], false for obj.name
}

MemberExpr represents dot or bracket member access. When Computed is false the expression is dot notation (Property is an Identifier). When Computed is true the expression is bracket notation (Property is any Node).

type Node

type Node interface {
	// contains filtered or unexported methods
}

Node is the common interface implemented by every AST node.

type NullLit

type NullLit struct{}

NullLit represents the null literal.

type NumberLit

type NumberLit struct {
	Value float64
}

NumberLit represents an integer or float literal, e.g. 42, 3.14.

type ObjectLit

type ObjectLit struct {
	Properties []Property
}

ObjectLit represents an object literal, e.g. { key: val, 'x': 1 }.

type OptionalMemberExpr

type OptionalMemberExpr struct {
	Object   Node
	Property Node
	Computed bool // true for obj?.[expr], false for obj?.prop
}

OptionalMemberExpr represents optional chaining member access: obj?.prop or obj?.expr. If the object is null or undefined the expression evaluates to undefined instead of returning an error.

type Pos

type Pos struct {
	Offset int // byte offset from the start of input
	Line   int // 1-based line number
	Col    int // 1-based column number
}

Pos records the byte offset, line, and column of a token within the source.

type Property

type Property struct {
	Key   string
	Value Node
}

Property is a single key-value pair in an object literal. Key is always a string (identifier or quoted string).

type StringLit

type StringLit struct {
	Value string
}

StringLit represents a single- or double-quoted string literal.

type TernaryExpr

type TernaryExpr struct {
	Condition  Node
	Consequent Node
	Alternate  Node
}

TernaryExpr represents condition ? consequent : alternate.

type Token

type Token struct {
	Type  TokenType
	Value string // raw source text of the token
	Pos   Pos
}

Token is a single lexical token produced by the Lexer.

func Tokenize

func Tokenize(src string) ([]Token, error)

Tokenize runs the lexer to completion and returns all tokens including EOF. On any error the returned error is non-nil and the token slice ends at the error token.

func (Token) String

func (t Token) String() string

type TokenType

type TokenType int

TokenType identifies the kind of a lexed token.

const (
	// Special
	TokenEOF TokenType = iota
	TokenError

	// Literals
	TokenInt
	TokenFloat
	TokenString

	// Identifier
	TokenIdent

	// Keywords
	TokenTrue
	TokenFalse
	TokenNull
	TokenUndefined
	TokenTypeof
	TokenVoid
	TokenIn
	TokenInstanceof
	// TokenNew is recognised so the parser can return a clear error.
	// The 'new' keyword is intentionally unsupported — this is a declarative
	// template language, not a full JavaScript evaluator.
	TokenNew

	// Arithmetic operators
	TokenStar     // *
	TokenSlash    // /
	TokenPercent  // %
	TokenPlus     // +
	TokenMinus    // -
	TokenStarStar // **

	// Bitwise / shift operators
	TokenAmp    // &
	TokenCaret  // ^
	TokenPipe   // |
	TokenTilde  // ~
	TokenLtLt   // <<
	TokenGtGt   // >>
	TokenGtGtGt // >>>

	// Relational operators
	TokenLt   // <
	TokenLtEq // <=
	TokenGt   // >
	TokenGtEq // >=

	// Equality operators
	TokenEqEq     // ==
	TokenBangEq   // !=
	TokenEqEqEq   // ===
	TokenBangEqEq // !==

	// Logical operators
	TokenBang     // !
	TokenAmpAmp   // &&
	TokenPipePipe // ||
	TokenQQ       // ??

	// Punctuation
	// Punctuation
	TokenLParen        // (
	TokenRParen        // )
	TokenLBracket      // [
	TokenRBracket      // ]
	TokenLBrace        // {
	TokenRBrace        // }
	TokenDot           // .
	TokenComma         // ,
	TokenColon         // :
	TokenQuestion      // ?
	TokenOptionalChain // ?.
)

func (TokenType) String

func (t TokenType) String() string

type UnaryExpr

type UnaryExpr struct {
	Op      string
	Operand Node
}

UnaryExpr represents a unary prefix expression. Op is one of: "!", "-", "+", "~", "typeof", "void".

type UndefinedLit

type UndefinedLit struct{}

UndefinedLit represents the undefined literal.

type UndefinedValue

type UndefinedValue struct{}

UndefinedValue is the type of the undefined sentinel.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL