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 @.

Data model — two types, nested arrays, undefined

A value is classified by its text:

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][= @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 '').

Assignment & emitting

Set a variable

[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.

Remove a variable — [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.

Emit a value — [= ]

[= @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

Dot-paths (nested to any depth)

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]

Comment

[# renders to nothing; may span multiple lines #]

Standalone directive/comment lines are trimmed (no leftover blank line). Pass --no-trim to keep them.

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".

See examples/conditions.ldt.

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.

Loop 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.

Expressions

[= 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

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.

Filters

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]
FilterBehavior
upper / lowerchange case
trimstrip surrounding whitespace
capitalizeuppercase the first character
truncate: n [, suffix]cut to n chars, append optional suffix
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])

Notes:

See examples/filters.ldt.

Escaping literal delimiters

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.

Feeding data in

A template can be driven by an external data context, not just variables it defines inline. Scalars are stringified (true1, false0, 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 mode

--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).

Run it

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');

Editor support

A TextMate bundle for PhpStorm lives in editor/phpstorm/ — see its README.

What is not possible

Every limitation fails loudly with a located error — never by silently producing wrong output. The complete list:

Not possibleWhyInstead
bare @name interpolating in body textwould mangle every email/@-mention[= @name]
filters in [if]/[for] conditionskeeps conditions simplefilter 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 levelplain ( ) grouping
[= ] as a range boundbounds are literals or refs[set hi = [= …]] first
unquoted | : , or hyphens-in-[= ] inside barewordsthey are expression tokensquote: "10:30", "super-admin"
a raw ]/[/set]/#] inside an unquoted value or commentdelimiter-based scanningquote the value ("a ] b") or escape: \], \[/set], \#]
an unpaired literal [ in a self-closing valuethe scan pairs [ ] so constructs nestescape it: \[
a value starting with a bare " that isn't properly quotedstrict quoting — fail loud, never manglequote the whole value, or escape: \"
rendering an array directlyarrays aren't text| join: ", "
nested commentsfirst #] closes\#] for a literal closer
bare \ escapes in expressions (outside strings)quoting covers it"quoted strings" — with \"/\\ inside
case-insensitive substring testsbyte-wise like ==[set e = [= @x | lower]] then test

Deliberately excluded features

Not oversights — decisions, recorded in TASKS.md, made to keep the language small:

FeatureWhy it's outWhat covers the need
includes / partialsfile resolution + recursion complexitycompose in the host app
macros / functionsthe line where a template becomes a programloops + sets
switch / caseredundant surface[if]/[elseif] chains
custom / host-registered filtersAPI-surface growththe built-in twelve
boolean / null literalstwo-type model already covers them; true would break bareword comparesflags are 1/0; undefined is the null
regex matchinga second syntax + escaping clashcontains / starts with / ends with
ternary ?:two mechanisms already existinline [if]…[else]…[/if] and default:

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.

Caveats & edge cases

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.

1. @ reads; writes are bare; text is literal

One rule covers every position:

PositionFormA bare @ is…
Body text and [set] valuesemit with [= @path]literal (so me@site.com and (@ada) are left intact)
Expressions — [if], [for … in], [= ]bare @paththe reference
Write targets — [set], [unset], [for k, v]bare name, no @

2. Writing a delimiter literally needs \

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 printWrite
[= (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\\

3. Escapes in expressions live inside quoted strings

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

4. A value/comment can't contain its own closer unescaped

Escape the closer to include it literally (see §2).

4b. Quoted [set] values — the strict rule

4c. [unset] removes; it never empties

4d. Values are mini-templates — the boundaries

5. Arithmetic in [= ] is integer-only

5b. count @array is array-length only

5c. Filters

5d. Substring operators

6. Inside [= ], - is subtraction

Because 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.)

7. [= ] cannot be nested in itself, and cannot be a range bound

8. --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.

9. Data types: a value is String or Number by its text

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).

9b. One falsy rule: [if] and default: agree

10. Loops

11. Whitespace trimming

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.

12. Seeded data

13. Resource limits

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.

Not a caveat anymore (resolved)

These earlier limitations were removed by later design changes and are listed only to avoid confusion: