ldt-lang
Logic Driven Text Language — a small, dependency-free embeddable templating engine for PHP. Everything is literal text until a bracket construct appears.
The engine is a four-stage Lexer → Trimmer → Parser → Interpreter pipeline with a bracket-tag surface syntax and a nested data model.
The whole surface in one breath: [...] is a construct, @path reads a variable (only inside constructs), [= expr] evaluates and emits, \ escapes — everything else is literal text. Writes take a bare name ([set name], [unset name], [for k, v]); reads carry the @. Every section below has a matching runnable file in examples/.
1. Data model — two types, nested arrays, undefined
A value is classified by its text, not by any declared type:
| Type | Rule | Examples |
|---|---|---|
| Number | text matches [+-]?digits(.digits)? | 5, -3, +5, 0.5, 007 |
| String | everything else | hi, on, 0x, 3px |
| Array | built by dot-path sets or seeded data; nests to any depth | items.0, user.first |
| undefined | a name never set — ldt's "null" | renders empty, is falsy |
The classification is a lens for comparison and truthiness — the original text is always preserved on output:
[set id = 007]
renders verbatim : [= @id]
compares as a number : [= @id == 7]
→ renders verbatim : 007
→ compares as a number : 1
Falsy = empty string, undefined, numeric zero (0, 0.0, -0, +0), and an empty array. Everything else is truthy — including the non-numeric string "0x" and a non-empty array. An array read directly still renders as '' and compares as '' — [if @items] tests presence, count @items its size.
A container that's been emptied down to zero elements (every key [unset]) is still defined — just falsy: defined @a is 1, but [if @a] takes the else branch. Booleans have no literal — a comparison result textualizes as 1/0, so it can be stored and re-tested like any Number.
See examples/data-model.ldt.
2. Assignment — [set] / [unset]
[set greeting]Hello[/set] block form — the body is the value (spaces kept)
[set name = world] self-closing form — value runs to ] (trimmed)
A [set]/[unset] name is a write target, so it is bare — no @. The @ always and only means read this value.
Values are trimmed at the ends in both forms. To keep leading/trailing spaces, quote the whole value — the outer quotes are stripped and the inside kept verbatim. A value that starts with an unescaped " must be a properly quoted whole value (closing quote last, interior quotes escaped as \") — anything else is a loud error, never a silently mangled value:
[set pad = " hello "] stores « hello » (no quotes)
[set say = "she said \"hi\""] interior quotes escaped as \"
[set lit = \"quoted\"] escaped leading quote → stores «"quoted"»
Values are mini-templates. A [set] value renders exactly like body text — into the variable instead of the output — so [= ], [if], [for] and nested self-closing [set]s execute inside values. The self-closing form scans bracket-aware, counting [ ] pairs so a computed [= ] nests directly:
[set greeting]Hello [if @vip]dear [/if][= @name][/set]
[set total = [= @price * @qty + 5]] nests fine — the scan pairs [ ]
[set stopped][for n in 1 to 5][if @n == 3][break][/if][= @n] [/for][/set]
A [break]/[continue] inside a value only binds to a [for] inside that same value — it cannot reach a loop outside it, since a value is its own render scope. An unpaired literal bracket in a self-closing value still needs escaping (\[ / \]) or the whole value quoted; a nested block set inside a block set is a loud error (the outer scan stops at the first [/set]). Errors inside values carry exact template coordinates.
Dot-paths nest to any depth. A . descends one level; a trailing dot appends at the next index; a purely numeric segment is normalized to an integer index, so .01 and .1 address the very same slot as an appended index 1. Intermediate arrays are created on demand.
[set fruit. = apple] append → fruit[0]
[set fruit. = banana] append → fruit[1]
[set user.first = Ada] keyed
[set order.items. = pen] nested + append → order.items[0]
[set a.01 = first][set a.1 = second] same slot: a.1 = second
Assigning a scalar over a map replaces it wholesale — [set a.b = 1][set a = x] silently drops the whole subtree, a.b becomes undefined. Only the reverse is loud: descending into a scalar ([set a = x][set a.b = 1]) is a cannot descend into scalar error. Negative indexes are valid keys ([set a.-1 = v]); an append landing after a lone negative key starts at 0 (PHP's max-int-key-plus-one rule).
[unset] removes; it never empties.
[unset draft] the name becomes undefined again
[unset user.email] remove one key; siblings stay
[unset a, b.c, items.1] multiple comma-separated paths
- Afterward
definedis0, strict mode errors on it, and fallbacks catch it — it's undefined, not an empty value. - Idempotent: a no-op when the path is already missing or passes through a scalar.
- Removing an array index leaves a hole — no reindexing; the next append continues past the old maximum.
- Removing the last key leaves a defined-but-falsy-empty array (
defined→ 1,[if]→ false,count→ 0). - Reset-a-container idiom:
[unset g][set g.k = v]— a plain[set g = ]would makega scalar and a laterg.kset would error.
See examples/assignment.ldt.
3. Emitting — [= ]
[= @greeting], [= @name]!
[= expr] evaluates an expression and emits the result — the ONE emit tag, for plain interpolation and for computed values alike. The simplest expression is a single reference, so [= @name] is the everyday interpolation. It has a clear close, so it splices anywhere — even mid-word: con[= @mid]tetur.
A @ in plain text is always literal — emails (a@b.com) and handles (@ada) need no escaping, ever; [= @path] is the only thing that reads a variable in body text.
Fallback for falsy values — the default: filter. It triggers on the same falsy rule [if] uses — undefined, empty, numeric zero, or an empty array. A genuine 0 is falsy too, so an attached fallback swallows it (don't attach one if a real zero must survive, or guard with defined first):
Hello [= @user.name | default: "guest"]!
[= @zero] a real 0, no fallback attached : 0
[= @zero | default: "-"] the same 0, WITH a fallback : -
[= @missing | default: @fb | default: "-"] cascades through falsy values
Its argument is a full expression, evaluated lazily — only when the fallback actually fires, so an error inside an unused fallback (| default: 1 / 0) never surfaces. A plain @ref argument reads the value raw, so an array can be the fallback for another array.
There is no self-nesting — [= ] is already expression context, so use plain ( ) grouping instead, and a range bound cannot be an [= ] expression (compute into a variable first):
[= [= 1 + 1] + 1] → error
[= (1 + 1) + 1] → ok
[for n in 1 to [= @x + 1]]… → error
[set hi = [= @x + 1]][for n in 1 to @hi]… → ok
See examples/emit.ldt.
4. Expressions
Available inside [= ], [if]/[elseif] conditions, and [for … in a to b] range bounds. References are bare @name, barewords are string literals.
Subtotal: [= @price * @qty]
precedence : [= 2 + 3 * 4] → 14 (* / % bind tighter than + -)
parens override it : [= (2 + 3) * 4] → 20
unary minus : [= -@a + 20]
- Integer arithmetic:
+ - * / %—/truncates toward zero; operands must be integers (a non-integer, or/ 0/% 0, is an error). A float literal like5.0is rejected the same way. - Comparisons
== != < > <= >=are type-aware: both operands numeric → numeric compare (5 == 5.0,007 == 7), otherwise lexicographic ("apple" < "banana"). defined @pathtests existence;count @arraygives an array's length (undefined →0, a scalar → a loud error). Both are context-sensitive, not reserved words — they act as operators only when followed by a@reference.- Boolean logic
and/or/not, with( )grouping. - Substring operators
contains/starts with/ends with— case-sensitive and byte-wise (like==); an empty needle is always found;starts/endsrequire thewith.
[= @file contains "port"] [= @file starts with "report"]
[= not @file contains "xlsx"] [= @file contains ""] → always 1
Because arithmetic is on inside [= ], - is the subtract operator — an unquoted hyphenated bareword parses as arithmetic and fails; quote it. The same trade-off hits a negative array index: @a.-1 reads fine in [if]/[for] (no arithmetic there), but inside [= ] the - is subtraction, so reach such a slot by iteration instead:
[= @role == super-admin] → error (parsed as super − admin)
[= @role == "super-admin"] → ok
Quoted strings support escapes (\", \\) and may span multiple lines — this is the only place a bare \ escape works inside an expression (see §8). A leading + is still a Number: +5 compares as 5, and +0 is falsy like -0.
See examples/expressions.ldt.
5. Filters
A postfix pipe chain transforms a value on its way out. It lives only in [= ] (never in [if]/[for] conditions); args follow a :, separated by commas, and each arg is a full expression:
[= @name | trim | upper]
[= @items | join: ", "]
[= @text | truncate: @width - 2, "…"]
| Filter | Behavior |
|---|---|
upper / lower | change case (ASCII-only — é passes through untouched) |
trim | strip surrounding whitespace |
capitalize | uppercase the first character |
truncate: n [, suffix] | cut to n bytes, never splitting a UTF-8 character |
join: sep | array → string (sep defaults to empty) |
first / last | first / last element of an array |
round [: n] | round to n decimals (default 0) |
abs | absolute value |
html | escape < > & " ' for HTML |
default: value | fallback when the input is falsy (same rule as [if]) |
- Filters apply to the finished value — compute first, then pipe.
- Arrays flow through the chain (into
join/first/last), but the final result must be a scalar — ending on an array is a loud "add a join" error. - A plain
@refargument reads the raw value, like the chain input — so an array can be thedefault:fallback for another array. Scalar-expecting args (truncate/round/join) still reject an array argument. truncateis UTF-8 safe: when the byte cut lands inside a multi-byte character, the incomplete tail is dropped before the suffix is added.defaultalone also satisfies--strictfor an undefined path; every other filter's arguments are never strict-guarded either way.- Arity is enforced — extra or missing filter arguments are a loud error; no-arg filters (
upper,trim, …) reject any argument.
See examples/filters.ldt.
6. Conditionals
[if @role == admin]Admin[elseif @role == editor]Editor[else]Guest[/if]
A reference is @name; a bareword is an unquoted string literal, so the @ is what marks "this is a variable". Every comparison/logic/substring operator from §4 works here — defined, count, and/or/not, contains/starts with/ends with — routing on any of them:
[if count @cart > 0]Cart has [= count @cart] item[if count @cart != 1]s[/if][else]Cart is empty[/if]
[if @path starts with "/api"]API route[else]Page route[/if]
[if @age >= 18 or (@age >= 13 and @hasConsent)]Allowed[else]Blocked[/if]
Falsy = empty string, undefined, numeric zero, and an empty array; everything else is truthy — a non-empty array included. Argument-less markers ([else], [/if]) accept whitespace before their closing ] — [else ], [/if ]. A duplicate [else], or an [elseif] placed after an [else], is a loud pointed error.
See examples/conditionals.ldt.
7. Loops
[for v in @items] … [/for] one-var: value only
[for k, v in @items] … [/for] two-var: key/index + value
[for n in 1 to 5] … [/for] inclusive range (add 'by 2' for a step)
[break] [continue] inside a loop only
The loop variables are declarations (writes), so they are bare — k, v — while the iterable is a read: @items. A bare integer alone is not iterable — [for n in 5] errors; ranges need the to keyword.
- Ranges are
a to b(inclusive), direction inferred (3 to 1counts down), optionalby step. Any bound — including the start — may be a@ref:@lo to @hi by @step. - Nested/record iteration: a loop value may itself be a sub-array, so
[= @v]renders empty but[= @v.name]resolves. [break]/[continue]bind to the nearest enclosing loop, even nested inside[if]s.- Loop variables are block-scoped — they don't leak, and any prior binding of the same name is restored afterward.
Loop metadata — inside a [for] body, @loop.* describes the iteration: loop.index (1-based), loop.index0 (0-based), loop.first, loop.last, and loop.count. Nested loops each get their own. A pre-existing variable of your own named loop is shadowed for the loop body and restored right after — but loop itself cannot be used as a loop variable name ([for loop in …] is an error: the metadata binding would make the value unreachable).
[for v in @items][= @v][if not @loop.last], [/if][/for] → a, b, c
A range's keys are 0-based positions, distinct from the 1-based @loop.index: [for k, n in 5 to 7] binds k to 0, 1, 2. An undefined array iterates zero times silently, but an undefined range bound is a loud error — bounds must resolve to an integer, and a literal or @ref bound past PHP_INT_MAX/PHP_INT_MIN is rejected the same way. Ranges stream lazily — a huge range costs time, never memory (see §12).
See examples/loops.ldt.
8. Escaping literal delimiters
A \ before any non-alphanumeric character emits it literally, so any delimiter can be written verbatim. A \ before a letter, digit, or end-of-line is itself literal, so ordinary prose and Windows paths (C:\Users) survive untouched — and @ is never a delimiter, so it never needs escaping anywhere in text:
\[= @x] → [= @x] (a literal emit tag, not evaluated)
\[set …] → [set …] (a literal tag)
\[/set] \[ \] \#] → literal brackets and closers
\\ → \
- An unpaired literal
[/]in a self-closing[set]value needs escaping too, since that form's scan pairs brackets so constructs can nest inside it. - A
[# … #]comment stops at the first literal#]— quotes do NOT protect a comment closer (quotes mean nothing in comments); escape it as\#]to keep the comment open past it. The#is not shared between opener and closer:[#]alone is an unterminated comment;[##]is the minimal valid one. - There is no bare
\escape inside an expression — only inside a"quoted string"does the same escape rule apply (see §4).
See examples/escaping.ldt.
9. Feeding data in
Two ways to drive a render from outside the template:
Ldt::render($source, $data = [], $strict = false, $file = null, $trim = true)
Ldt::renderFile($path, $data = [], $strict = false, $trim = true)
ldt --set user.first=Ada file.ldt # dotted key → nested path
ldt --json data.json file.ldt # a whole JSON object as the context
ldt --json data.json --set site=X f.ldt # later flags win
$data is a plain PHP array — scalars are stringified (true→"1", false→"0", null→""), nested arrays stay nested and are addressed by dot-path, and an inline [set] can override anything seeded before the render.
- A seeded
nullis an empty scalar, not an absent key — it reads as""and isdefined; descending into it with a template[set]is a path conflict. Omit the key entirely to let the template build the container instead. --jsondeep-merges maps key by key, but a JSON list replaces the previous value wholesale — no element-wise merge.- A purely numeric-looking key collides with an integer array position, the same rule as dot-paths (§2): a JSON object key
"1"and a JSON list's second element (index1) address the very same slot if merged onto the same path. - Seeded values must be scalars,
null, or arrays — anything else (an object, a resource) is a loud error at seed time. Non-finite floats (INF/NAN) are rejected too, since they'd stringify to words, not Numbers.
See examples/feeding-data-in.ldt with examples/data.json.
10. Strict mode
--strict (CLI) / $strict = true (PHP) makes an undefined plain-reference [= @path] — filtered or not — an error instead of empty, unless its chain has a default:. That is the only thing it guards:
--strict [= @missing] → error: undefined reference
--strict [= (@missing)] → error too — parens parse away, still a plain ref
--strict [if @missing]… → takes the false branch (no error — existence test)
--strict [for v in @missing] → zero iterations (no error — existence test)
--strict [= @missing == ""] → renders 1 — unguarded inside a computed expression
--strict [= @missing | default: "x"] → renders "x" — default: satisfies it
[if]/[for] are the existence tests and stay silent by design — changing that would break the idiom. Filter arguments are never strict-guarded either, default: included. Arithmetic on an unguarded '' still fails, but that's a type error in any mode, not something --strict specifically causes.
11. Whitespace trimming
A line whose only content is directives/comments (no real text, no [= ] output) is standalone: it's removed along with its trailing newline, so a tag on its own line leaves no blank line behind. A line that also has real text, or an [= ] emit, is left untouched — an emit line is never trimmed even if it looks bare. Pass --no-trim to disable this globally.
- Escaped whitespace does not protect a line — by the time the trimmer decides,
\is already plain whitespace text, so a directive line padded only with escaped spaces still trims away, same as if the spaces were unescaped. To keep a line, give it visible text or an[= ]. - Only
\nends a line for the trimmer. CRLF files work naturally (the\rtrims away with its line), but a CR-only file (classic Mac endings) is one long line — nothing trims, and the\rs render through as ordinary text.
See examples/whitespace-trimming.ldt (run it once plain, once with --no-trim, and diff the two).
12. Resource limits
Templates are trusted input, the host app's own files — not sandboxed against an adversarial author. There is deliberately no enforced depth or iteration cap: moderate nesting and moderate loops cost exactly what the equivalent native PHP would cost.
- Ranges stream lazily — a huge range (
1 to 100000000000) costs time, never memory, since the sequence is never materialized as an array. - Range bounds must still fit a platform int — an out-of-range bound is a loud error, never silent overflow.
- ~100,000 levels of nested
[if]has been verified to work (20,000+ deep is comfortably fine), but going deep enough eventually hits PHP's own memory/call-stack limit as a raw fatal error, not a cleanSyntaxError— a deliberate trade-off; an arbitrary depth cap would solve a problem nobody has, but it's worth knowing before generating templates programmatically.
See examples/resource-limits.ldt.
13. What is not possible
Every limitation fails loudly with a located error — never by silently producing wrong output. Some things are also simply not in the language by design — decisions recorded in TASKS.md, made to keep the language small — with an idiom that already covers the realistic need:
| Not in the language | Use instead |
|---|---|
switch / case | [if] / [elseif] / [else] |
ternary ?: | inline [if]…[else]…[/if], or | default: for a falsy-value shorthand |
| regex matching | contains / starts with / ends with (real validation belongs in the host app) |
boolean / null literals (true/false/null keywords) | a comparison result textualizes as 1/0; undefined already is the null |
| custom / host-registered filters | the fixed built-in set (§5); pre/post-process in the host app |
[include] / [macro] (template composition) | render sub-pieces separately in PHP and pass the strings in as data |
float arithmetic in [= ] | keep math integer; the round filter formats decimals |
bare @name interpolating in body text | [= @name] — otherwise every email/@-mention would mangle |
filters in [if]/[for] conditions | filter into a [set] first, or use [= ] |
| rendering an array directly as text | | join: ", ", or iterate |
See examples/not-possible.ldt.
Run it
Via Composer — syncroze/ldt-lang on Packagist:
composer require syncroze/ldt-lang
require 'vendor/autoload.php';
use Ldtlang\Ldt;
echo Ldt::render('Hello [set who = world][= @who]!');
echo Ldt::renderFile('template.ldt');
The CLI ships at vendor/bin/ldt:
vendor/bin/ldt examples/assignment.ldt # render a file
vendor/bin/ldt --strict file.ldt # error on undefined references
vendor/bin/ldt --no-trim file.ldt # keep standalone-line blank lines
vendor/bin/ldt --tokens file.ldt # dump the token stream
vendor/bin/ldt - # render stdin
No Composer? The engine also runs from a plain clone of the repository — clone it, then:
php bin/ldt examples/assignment.ldt # render a file
php tests/run.php # run the test suite
require 'autoload.php';
use Ldtlang\Ldt;
echo Ldt::render('Hello [set who = world][= @who]!');
echo Ldt::renderFile('examples/assignment.ldt');
Editor support
A TextMate bundle for PhpStorm lives in editor/phpstorm/ — see its README.
Status
Assignment, the [= expr] emit tag (plain-reference interpolation, integer arithmetic + comparison/logic, | default: fallbacks and the full filter set), nested dot-paths, comments, conditionals, loops (arrays + to/by ranges, break/continue, nested iteration, @loop.* metadata), an external data context (PHP array / CLI --set / --json), whitespace trimming, and \ escaping are implemented.
Planned work and deliberately-excluded features are tracked in TASKS.md; the full design/decision log, including every rejected alternative, is in HISTORY.md.