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:
- The caller-supplied scope map (map[string]any).
- The built-in function table (registered via RegisterBuiltin).
- 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 ¶
- Variables
- func CollectIdentifiers(src string) ([]string, error)
- func Eval(src string, scope map[string]any) (any, error)
- func IsTruthy(v any) bool
- func RegisterBuiltin(name string, fn func(...any) (any, error))
- type ArrayLit
- type BinaryExpr
- type BoolLit
- type CallExpr
- type Expr
- type Identifier
- type Lexer
- type MemberExpr
- type Node
- type NullLit
- type NumberLit
- type ObjectLit
- type OptionalMemberExpr
- type Pos
- type Property
- type StringLit
- type TernaryExpr
- type Token
- type TokenType
- type UnaryExpr
- type UndefinedLit
- type UndefinedValue
Examples ¶
Constants ¶
This section is empty.
Variables ¶
var Undefined = UndefinedValue{}
Undefined is the Go representation of the JavaScript `undefined` value.
Functions ¶
func CollectIdentifiers ¶
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]
Types ¶
type ArrayLit ¶
type ArrayLit struct {
Elements []Node
}
ArrayLit represents an array literal, e.g. [1, 'two', item].
type BinaryExpr ¶
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 Expr ¶
Expr is a compiled expression ready for repeated evaluation.
func Compile ¶
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 ¶
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.
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 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 ¶
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 ¶
TernaryExpr represents condition ? consequent : alternate.
type Token ¶
Token is a single lexical token produced by the Lexer.
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 // ?. )
type UnaryExpr ¶
UnaryExpr represents a unary prefix expression. Op is one of: "!", "-", "+", "~", "typeof", "void".
type UndefinedValue ¶
type UndefinedValue struct{}
UndefinedValue is the type of the undefined sentinel.