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 @.
A value is classified by its text:
| 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][= @id] renders verbatim, but compares as a number: [= @id == 7]
→ 007 renders verbatim, but compares as a number: 1
Falsy = empty string, undefined, numeric zero (0, 0.0, -0), and an empty array. Everything else is truthy — including the non-numeric string 0x and a non-empty array ([if @items] tests presence; count @items its size; in a comparison an array still reads as '').
[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:
[set pad = " hello "] stores « hello » (no quotes)
[set say = "she said \"hi\""] interior quotes escaped as \"
[set lit = \"quoted\"] escaped leading quote → stores «"quoted"»
A value that starts with an unescaped " must be a properly quoted whole value (closing quote last, interior quotes escaped) — anything else is an error, never a silently mangled value.
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 counts [ ] pairs, so nested constructs keep their own closers:
[set greeting]Hello [if @vip]dear [/if][= @name][/set]
[set msg = Hello [= @name]!]
[set total = [= @price * @qty + 5]]
[set list][for x in @items][= @x],[/for][/set]
Escape a literal tag (\[if) — and an unpaired literal bracket in a self-closing value (\[ / \]), since the scan pairs them. Two boundaries: a nested block set inside a block set is a loud error (the outer scan stops at the first [/set]), and a [break]/[continue] inside a value cannot affect a loop outside it — a value is its own render scope. Errors inside values carry exact template coordinates.
[unset][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
[unset path] removes the value entirely (subtree included) — afterward defined is false, strict mode errors on it, and fallbacks catch it. It is a no-op when the path doesn't exist (idempotent). Removing an array index leaves a hole (no reindexing); [unset g][set g.k = v] is the idiom for resetting a container.
[= ][= @greeting], [= @name]!
[= expr] evaluates an expression and emits the result. 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.
Fallback for falsy values — the default: filter. It triggers when the value is falsy — undefined, empty, numeric zero, or an empty array — the same rule [if] uses. 007 and "0x" are truthy and pass through. Its argument is a full expression, and chaining gives cascading fallbacks:
Hello [= @user.name | default: "guest"]!
[= @price | default: @basePrice] a variable fallback
[= @missing | default: @fb | default: "-"] cascades through falsy values
A . descends one level. A trailing dot means "append at the next index". A numeric segment is an index; a name segment is a key. Intermediate arrays are created on demand; descending through a scalar is an error.
[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 order.items.0.note = x] nested keyed leaf
[= @fruit.1] [= @user.first] [= @order.items.0]
[# renders to nothing; may span multiple lines #]
Standalone directive/comment lines are trimmed (no leftover blank line). Pass --no-trim to keep them.
[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".
== != < > <= >=; both operands numeric → numeric compare (5 == 5.0, 007 == 7), otherwise lexicographiccontains, starts with, ends with — case-sensitive, on the text form; usable in [= ] too and negated with not: [if @file ends with ".pdf"], [if @email contains "@"]and / or / not with parentheses ( )defined @path tests existence"quotes"[if @items])] is the body boundary, so [if @a] yes[/if] keeps the leading space as bodySee examples/conditions.ldt.
[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.
@order.items)[= @v] renders empty but [= @v.name] resolves — loop over recordsa to b (inclusive), direction inferred (3 to 1 counts down), optional by step (positive). Any bound may be a @ref, including the start (@lo to @hi by @step). Ranges stream lazily — a huge range costs time, never memory; bounds must fit the platform integer[break] / [continue] bind to the nearest enclosing loop; output emitted before them is keptLoop metadata — inside a [for] body, @loop.* describes the iteration: loop.index (1-based), loop.index0 (0-based), loop.first, loop.last (each 1/0), and loop.count (the total). Nested loops each get their own.
[for v in @items][= @v][if not @loop.last], [/if][/for] → a, b, c
See examples/loops.ldt.
[= expr] is the emit tag: it evaluates an expression and writes the result into the output. It works anywhere text is emitted (body output and [set] values), and inside it you're in expression context: references are bare @name, barewords are string literals, arithmetic is on.
Subtotal: [= @price * @qty]
[set total = [= @price * @qty + 5]] capture a computed value
[for n in 1 to 3][= @n * @n] [/for] → 1 4 9
+ - * / % — / truncates toward zero; * / % bind tighter than + -; ( ) groups; unary - negates. Operands must be integers (a non-integer, or / 0, is an error).==, <, and, not, defined, …); a true/false result renders as 1 / 0 — handy for flags: [set adult = [= @age >= 18]].count @array gives an array's length (undefined → 0, a scalar → error). It's an expression operator, so it works in [= ] and in [if] conditions:
You have [= count @items] item[if count @items != 1]s[/if].
[if count @cart > 0]…[/if]
[= is written \[=.The one thing to know: inside [= ], - is subtraction, so an unquoted bareword with a hyphen (some-value) must be quoted ("some-value"). Plain [if]/[for] conditions don't enable arithmetic, so hyphenated barewords keep working there unchanged. See examples/expressions.ldt.
A postfix pipe chain transforms a value on its way out. It lives in [= ]; args follow a :, separated by commas, and each arg is a full expression:
[= @name | trim | upper]
[= @items | join: ", "]
[= @price | round: @precision]
[= @text | truncate: @width - 2, "…"]
[= count @cart * 10 | abs]
| Filter | Behavior |
|---|---|
upper / lower | change case |
trim | strip surrounding whitespace |
capitalize | uppercase the first character |
truncate: n [, suffix] | cut to n chars, append optional suffix |
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]) |
Notes:
join/first/last), but the final result must be a scalar — an array at the end is an error ("add a join").default also satisfies --strict for an undefined path.[if]/[for] tag conditions — filter into a [set] first, or use [= ].| in plain text is literal, as always.See examples/filters.ldt.
A \ before any non-alphanumeric character emits it literally, so any delimiter can be written verbatim:
\[= @x] → [= @x] (a literal emit tag, not evaluated)
\[set …] → [set …] (a literal tag)
\[/set] \[ \] \#] → literal brackets and closers
\\ → \
A \ before a letter, digit, or end-of-line is itself literal, so ordinary text and Windows paths (C:\Users) are untouched — and @ is never special in text, so it needs no escaping at all. In expressions ([if], [for], [= ]) there are no bare escapes — use a "quoted" string for special characters; inside such a string the same escape rule applies (\" for a quote, \\ for a backslash), and strings may span lines. See examples/limitations.ldt.
A template can be driven by an external data context, not just variables it defines inline. Scalars are stringified (true→1, false→0, null→empty), nested arrays are addressed by dot-path, and an inline [set] may override a seeded value.
From PHP:
echo Ldt::render('Hello [= @user.first]!', ['user' => ['first' => 'Ada']]);
echo Ldt::renderFile('examples/data.ldt', ['cart' => [['item' => 'Pen', 'qty' => 2]]]);
From the CLI:
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
See examples/data.ldt with examples/data.json.
--strict (CLI) / strict: true (PHP) turns an undefined [= @path] into an error instead of empty. A default: filter satisfies it; any other filter chain on an undefined path still errors. Refs inside a computed expression resolve to empty rather than erroring (and then fail arithmetic if misused).
php bin/ldt examples/assignments.ldt # render a file
php bin/ldt --strict file.ldt # error on undefined references
php bin/ldt --tokens file.ldt # dump the token stream
php bin/ldt - # render stdin
php tests/run.php # run the test suite
Or from PHP:
require 'autoload.php';
use Ldtlang\Ldt;
echo Ldt::render('Hello [set who = world][= @who]!');
echo Ldt::renderFile('examples/assignments.ldt');
A TextMate bundle for PhpStorm lives in editor/phpstorm/ — see its README.
Every limitation fails loudly with a located error — never by silently producing wrong output. The complete list:
| Not possible | Why | Instead |
|---|---|---|
bare @name interpolating in body text | would mangle every email/@-mention | [= @name] |
filters in [if]/[for] conditions | keeps conditions simple | filter into a [set], or use [= ] |
float arithmetic ([= 1.5 + 1]) | integer-only by design (no float-formatting swamp) | keep math integer; round formats decimals |
nested [= [= ] ] | one expression level | plain ( ) grouping |
[= ] as a range bound | bounds are literals or refs | [set hi = [= …]] first |
unquoted | : , or hyphens-in-[= ] inside barewords | they are expression tokens | quote: "10:30", "super-admin" |
a raw ]/[/set]/#] inside an unquoted value or comment | delimiter-based scanning | quote the value ("a ] b") or escape: \], \[/set], \#] |
an unpaired literal [ in a self-closing value | the scan pairs [ ] so constructs nest | escape it: \[ |
a value starting with a bare " that isn't properly quoted | strict quoting — fail loud, never mangle | quote the whole value, or escape: \" |
| rendering an array directly | arrays aren't text | | join: ", " |
| nested comments | first #] closes | \#] for a literal closer |
bare \ escapes in expressions (outside strings) | quoting covers it | "quoted strings" — with \"/\\ inside |
| case-insensitive substring tests | byte-wise like == | [set e = [= @x | lower]] then test |
Not oversights — decisions, recorded in TASKS.md, made to keep the language small:
| Feature | Why it's out | What covers the need |
|---|---|---|
| includes / partials | file resolution + recursion complexity | compose in the host app |
| macros / functions | the line where a template becomes a program | loops + sets |
| switch / case | redundant surface | [if]/[elseif] chains |
| custom / host-registered filters | API-surface growth | the built-in twelve |
| boolean / null literals | two-type model already covers them; true would break bareword compares | flags are 1/0; undefined is the null |
| regex matching | a second syntax + escaping clash | contains / starts with / ends with |
ternary ?: | two mechanisms already exist | inline [if]…[else]…[/if] and default: |
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 complete list of edge cases and "gotchas" to be aware of. Most are inherent to a delimiter-based embeddable language; each has a clear workaround. For a runnable escaping demo see examples/limitations.ldt.
@ reads; writes are bare; text is literalOne rule covers every position:
| Position | Form | A bare @ is… |
|---|---|---|
Body text and [set] values | emit with [= @path] | literal (so me@site.com and (@ada) are left intact) |
Expressions — [if], [for … in], [= ] | bare @path | the reference |
Write targets — [set], [unset], [for k, v] | bare name, no @ | — |
@name in body text does not interpolate — it prints literally. Use [= @name] to emit in text.
value @a here → value @a here (literal)
value [= @a] here → value X here (emitted)
@ — [set @name = x] is an error ("expected a variable path after [set"). The name is where the value goes, not a value being read.\Every construct's opener/closer is meaningful, so to print one literally, escape it. The rule: \ before a non-alphanumeric character emits that character literally; before a letter, digit, or end-of-line the \ is itself literal (so C:\Users and ordinary prose backslashes survive). @ is not a delimiter — it never needs escaping in text.
| To print | Write |
|---|---|
[= (an emit tag) | \[= |
a tag like [set …], [if …], [# … | \[set …], \[if …], \[# |
a ] or unpaired [ in an unquoted = value, or [/set] in a block value | \], \[, \[/set] |
a comment closer #] (inside a comment) | \#] |
| a literal backslash | \\ |
Bare \ escapes don't exist in [if], [for], or [= ] — but inside a "..." string literal the universal escape rule applies (\ before a non-alphanumeric char yields it; before a letter, digit or end-of-line it stays literal), and strings may span newlines:
[if @x == "a]b"]… quotes hold the special character
[if @x == "say \"hi\""]… an escaped quote inside the string
[if @p == "C:\Users"]… backslash before a letter is literal
[set x]…[/set] value stops at the first literal [/set] — unless the body is a quoted whole value, which protects it: [set x]"a [/set] b"[/set] needs no escape.[set x = …] value is scanned bracket-aware: nested […] constructs pass through whole, and the value ends at the first ] at depth 0. Consequence: an unpaired literal [ or ] must be escaped (\[ / \]) or the value quoted ([set x = "a ] b"]). Only a whole-value quote protects — quotes in the middle of a value are ordinary characters, so [set a = say "[" here] is an unterminated-value error (write \[).[# … #] comment stops at the first literal #]. Quoting does not protect it (quotes mean nothing in comments) — escape it as \#].# cannot be shared between opener and closer: [#] is an unterminated comment. The minimal comment is [##].Escape the closer to include it literally (see §2).
[set] values — the strict rule[set pad = " x "] stores x ." must be a properly quoted whole value: the closing " is the last character and interior quotes are escaped (\"). Otherwise it is an error:
[set a = "yes" or "no"] → error — write "yes\" or \"no"
[set q]"[/set] → error — a lone quote is \"
\"...\".[set s = say "hi" now] is fine.[unset] removes; it never empties[unset path] (or [unset a, b.c]) makes the path undefined — not an empty value: defined → 0, strict mode errors, fallbacks catch it.count drops, the remaining keys keep their positions, and the next append continues past the old maximum (0,1,2 → unset 1 → append lands on 3). Negative indexes are valid keys ([set a.-1 = v]); an append past a lone negative key starts at 0 (PHP's max-int-key-plus-one rule).defined → 1, but [if @a] is false and count @a is 0 (empty-array falsy rule).[set a.b = 1][set a = x] silently drops the whole subtree (a.b becomes undefined). Only the other direction is loud: descending into a scalar ([set a = x][set a.b = 1]) is a cannot descend into scalar error.[unset g][set g.k = v] (a plain [set g = ] would make g a scalar and later g.k sets would error).[set] value renders exactly like body text, with two boundaries: a [break]/[continue] inside a value binds only to a [for] inside the same value — it cannot break a loop outside it; and a nested block set errors loudly (self-closing sets nest fine).[for]/[set]/[unset] headers may wrap across lines. Value regions are unaffected.[else], [/if], [/for], [break], [continue]) accept whitespace before their ] too — [else ] is the tag, [else x] stays literal. The value/comment closers [/set] and #] remain exact sentinels: no interior whitespace.[= ] is integer-only5.0 — is an error:
[= 5.0 + 1] → error: "arithmetic requires integer operands, got '5.0'"
/ is integer division (truncates toward zero): [= 10 / 3] → 3.[= 5 / 0], [= 5 % 0].count @array is array-length onlycount @path returns the number of elements: undefined → 0, a scalar → error ("cannot count @x: not an array"), same spirit as [for] over a non-array. It is not string length.count and defined are context-sensitive, not reserved (like the substring operators): they act as operators only when followed by a @reference, so [if @x == count] compares against the literal word.[= ] — in an [if]/[for] tag condition a | is an error ("filters are not supported in a tag condition"). Filter into a [set] first, or use [= ].|, : and , are expression tokens. An unquoted bareword cannot contain them — quote such literals ([if @t == "10:30"], join: ", ").- is subtraction and barewords with hyphens/commas must be quoted (same rule as the rest of [= ]).@ref argument reads the raw value (like the chain input), so an array can be a default: fallback: [= @list | default: @fallbackList | join: ", "]. Scalar-expecting arguments (truncate / round / join) reject an array argument loudly.default: evaluates its argument lazily — only when the fallback actually fires. An error inside an unused fallback (say, a division by zero) never surfaces; every other filter needs its arguments and evaluates them eagerly.join/first/last), but an array at the end is an error.default vs --strict: a default filter satisfies strict mode for an undefined path; any other chain on an undefined path still errors under --strict.filter 'upper' takes no arguments).| — no new global reserved words.upper / lower / capitalize transform a–z / A–Z only; every other byte passes through (é stays é) — the same dependency-free byte policy as the substring operators.truncate counts bytes but never splits a UTF-8 character: when the cut lands inside a multi-byte sequence, the incomplete tail is dropped before the suffix is added (a complete character at the edge is kept). Assumes UTF-8 text — a stray Latin-1 high byte at the cut is dropped too.round / abs are float-based: beyond ~15 significant digits PHP float precision applies, and a huge result renders in scientific notation (1.23…E+29) — which is then not a Number by the text rule, so later arithmetic on it errors. Keep filter math at ordinary magnitudes; [= ] integer arithmetic is exact within the platform integer range.contains / starts with / ends with are case-sensitive and byte-wise (like string ==). For a case-insensitive test, lowercase first: [set e = [= @email | lower]][if @e contains …].[if @x == contains] still compares against the literal word.@x contains "" is true), matching PHP.starts / ends require the with (starts "x" is an error).[= ], - is subtractionBecause arithmetic is on inside [= ], a hyphen is the subtract operator, so an unquoted bareword with a hyphen is parsed as arithmetic and fails. Quote it:
[= @role == super-admin] → error (parsed as super − admin)
[= @role == "super-admin"] → ok
The same trade-off hits a negative array index: @a.-1 works in [if]/[for] conditions (no arithmetic there), but inside [= ] the - reads as subtraction, so [= @a.-1] is an error. To emit such a slot, reach it by iteration ([for k, v in @a][if @k == -1][= @v][/if][/for]) — or avoid negative keys in data you need to print directly.
(Plain [if]/[for] conditions do not enable arithmetic, so hyphenated barewords like [if @x == some-value] keep working there unchanged.)
[= ] cannot be nested in itself, and cannot be a range bound[= ] inside [= ] — the inside is already expression context; use plain grouping ( ):
[= [= 1 + 1] + 1] → error
[= (1 + 1) + 1] → ok
(Nesting [= ] inside a [set … = …] value is fine — a value is text, not an expression.)[= ] expression — bounds are integer literals or bare @refs. Compute first, then use the variable:
[for n in 1 to [= @x + 1]]… → error
[set hi = [= @x + 1]][for n in 1 to @hi]… → ok
--strict guards only plain-reference emits--strict makes an undefined [= @path] (a plain reference, filtered or not) an error — unless its chain has a default:. Everywhere else undefined is a value, not an error — conditions and loops are the existence tests, so they stay silent by design:
--strict x[= @missing]y → error: undefined reference
--strict [if @missing]… → takes the false branch (no error)
--strict [for v in @missing] → zero iterations (no error)
--strict x[= @missing == ""]y → x1y — refs inside a computed expression
read as '' with no guard (arithmetic on
such a ref still fails, in any mode:
"…got ''")
A defined value never errors under strict either — including an array rendered directly: [= @arr] gives '' (arrays are not renderable; add a join or iterate).
Filter arguments are expressions, so they are never strict-guarded — including a default: fallback: [= @missing | default: @alsoMissing] renders empty under --strict, with no error (the chain has a default:, and the undefined fallback reads as '').
The guard follows the plain-reference emit wherever it appears — an undefined [= @path] inside a [set] value errors too, at its exact template coordinates.
A value is treated as a Number when its text matches [+-]?\d+(\.\d+)?, otherwise a String. The classification is used for comparison and truthiness; the original text is always preserved on output (+5 renders as +5, compares as 5).
007 renders as 007 but compares as 7:
[set id = 007][= @id] but [= @id == 7] → 007 but 1
5 == 5.0), otherwise lexicographic (apple < banana).0, 0.0, -0, +0). Everything else is truthy — including a non-numeric "0x".1 / 0 — a comparison result stored or emitted is a Number ([set f = [= 1 > 2]][= @f] → 0), so [= (…) == 0] works.[if] and default: agree| default: "F") triggers exactly when [if @x] would be false: undefined, empty string, numeric zero, or an empty array. Its argument is a full expression (| default: @fb), and chained defaults cascade through falsy values.0 cannot survive an attached fallback — [set n = 0][= @n | default: "-"] renders -. To display a zero, don't attach a fallback (or guard with [if defined @n]).007 (numeric 7) and "0x" (non-numeric) are truthy and pass through.[if] / not / and / or (an empty array is falsy) — the same rule default: applies, so the two always agree here too. In a comparison (or as text) an array still reads as ''; test presence with [if @items], size with count @items.to: [for n in 5] errors; [for n in 1 to 5] is the range.[= @v] renders empty (an array isn't directly renderable) but [= @v.field] resolves.[break] / [continue] bind to the nearest enclosing loop, even when nested inside [if]s.loop is bound inside every [for] body (for [= @loop.index] etc.). A variable of your own named loop is shadowed inside the loop and restored after it — same block-scoping as the loop variables.loop cannot be a loop variable name — [for loop in …] (or [for k, loop in …]) is an error: the metadata binding would make the value unreachable. Pick another name; only a pre-existing variable named loop is silently shadowed (previous bullet).[for k, n in 5 to 7] binds k to 0, 1, 2 (a range behaves like a list), while @loop.index stays 1-based. With an array, k is the array's own key.[for v in @missing] (zero iterations, see §8), [for n in 1 to @missing] fails loudly: bounds must be integers, and "no integer" is not a usable bound.@ref bound beyond PHP_INT_MAX/PHP_INT_MIN is a loud error (PHP would otherwise silently saturate it). Ranges ending exactly at the limits work.[for n in 1 to 1000000000] costs time, never memory: values are produced one at a time and loop.count is computed arithmetically. (Only the loop's own output occupies memory.)A line whose only content is directives/comments (no text, no [= ] output) is standalone: it's removed along with its trailing newline, so a tag on its own line leaves no blank line. A line that also has real text or an emit is left untouched. Pass --no-trim to disable this.
\ (escaped space) is plain whitespace text, so a directive line padded only with escaped spaces still trims. To keep a line, give it visible text or an [= ].\n ends a line. CRLF files work naturally (the \r trims away with its line), but CR-only line endings (classic Mac) are not line endings: such a file is one long line — nothing trims, and the \rs render through as ordinary text.true → "1", false → "0" (an answer), null → "" (the absence of one). All of false/null remain falsy; false never becomes the string "false".[set] overrides a seeded value; via the CLI, a later --set / --json overrides an earlier one. --json merging is map-wise: objects deep-merge key by key, but a JSON list replaces the previous value wholesale (overriding ["a","b","c"] with ["x"] yields ["x"] — no leftover tail).--set a.b=c builds the nested path a.b; the value is always a string. Keys are validated as dot-paths (--set 'a b=c' is a CLI error). Keys in seeded PHP arrays are the host app's responsibility — invalid ones are unreachable from templates..01, .1 and an appended slot 1 all address the same key (PHP's array keying). A seeded string key like '01' (which PHP keeps as a string) is therefore unreachable from templates: [= @a.01] reads the int key 1 and misses.null is an empty scalar, not an absent key — descending into it ([set a.b = x] with ['a' => null]) is a path conflict. Omit the key entirely to let the template build the container.InvalidArgumentException naming the offending key path. Non-finite floats (INF / NAN) are rejected the same way; an ordinary float seeds by its PHP string form, which beyond ~15 significant digits is scientific notation (1.0E+20) — no longer a Number by the text rule.--set a=x --set a.c=2 silently rebuilds a as a map (the scalar is dropped). Inline [set] is a template mutation and errors on the same conflict (cannot descend into scalar) — seeding describes a desired end state; the template protects its own.Templates are the host app's own files — trusted input. There is deliberately no nesting-depth or iteration cap: a pathological template (e.g. ~100,000 nested [if]s) eventually hits PHP's own stack/memory limits as a fatal error rather than a SyntaxError. Realistic nesting (tested to 20,000 deep) and arbitrarily long-running range loops are fine.
These earlier limitations were removed by later design changes and are listed only to avoid confusion:
[= @x] has a clear close, so con[= @x]tetur works.@ref — fixed by the to/by keywords (@lo to @hi by @step).[set total = [= @price * @qty]] nests directly.