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:

TypeRuleExamples
Numbertext matches [+-]?digits(.digits)?5, -3, +5, 0.5, 007
Stringeverything elsehi, on, 0x, 3px
Arraybuilt by dot-path sets or seeded data; nests to any depthitems.0, user.first
undefineda 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 defined is 0, 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 make g a scalar and a later g.k set 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 like 5.0 is rejected the same way.
  • Comparisons == != < > <= >= are type-aware: both operands numeric → numeric compare (5 == 5.0, 007 == 7), otherwise lexicographic ("apple" < "banana").
  • defined @path tests existence; count @array gives 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/ends require the with.
[= @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, "…"]
FilterBehavior
upper / lowerchange case (ASCII-only — é passes through untouched)
trimstrip surrounding whitespace
capitalizeuppercase the first character
truncate: n [, suffix]cut to n bytes, never splitting a UTF-8 character
join: separray → string (sep defaults to empty)
first / lastfirst / last element of an array
round [: n]round to n decimals (default 0)
absabsolute value
htmlescape < > & " ' for HTML
default: valuefallback 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 @ref argument reads the raw value, like the chain input — so an array can be the default: fallback for another array. Scalar-expecting args (truncate/round/join) still reject an array argument.
  • truncate is UTF-8 safe: when the byte cut lands inside a multi-byte character, the incomplete tail is dropped before the suffix is added.
  • default alone also satisfies --strict for 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 1 counts down), optional by 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 null is an empty scalar, not an absent key — it reads as "" and is defined; descending into it with a template [set] is a path conflict. Omit the key entirely to let the template build the container instead.
  • --json deep-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 (index 1) 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 \n ends a line for the trimmer. CRLF files work naturally (the \r trims 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 clean SyntaxError — 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 languageUse instead
switch / case[if] / [elseif] / [else]
ternary ?:inline [if]…[else]…[/if], or | default: for a falsy-value shorthand
regex matchingcontains / 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 filtersthe 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] conditionsfilter 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.