LCL V1 language syntax

LCL V1 is lclang's versioned, expression-only configuration language. It borrows familiar Python spellings while using its own lexer, custom immutable AST, parser, canonical printer, and async interpreter. It never compiles through the Python AST, eval, exec, or bytecode.

This guide describes the implemented expression language. The implemented .lclcfg format adds colon definitions and source-ordered using expansion around these expressions.

Lexical structure

Source is Unicode text. Positions use one-based lines/columns and zero-based Unicode code-point offsets. Spaces, tabs, form feeds, and physical newlines separate tokens. Newlines are discarded before expression parsing, so a single expression may span lines without backslashes. A # starts a comment through the physical end of line, except inside a quoted literal or f-string field.

Identifiers follow Python's Unicode str.isidentifier rules. These lowercase words are reserved: and, or, not, if, else, for, in, is, true, false, none, raise, try, except, finally, assert, with, and as. Python-compatible True, False, and None are also accepted. Canonical printing uses True, False, and None.

FRAME_PROXY is a special constant used only as a complete named binding value. It declares that binding as a scoped prefix rather than a real value. Nested uses such as [FRAME_PROXY] are invalid. Prefix declarations are optional when qualified descendants already infer the same proxy.

Names introduced at binding positions cannot begin with __. This applies to record fields, function parameters, comprehension targets, exception aliases, and context manager aliases. Ordinary references, attributes, and call keywords are not binding positions. File-backed .lclcfg parsing additionally replaces __file__ and __dir__ references with eager path string constants.

During Frame evaluation of a named Module/config definition, the root lhs() function returns that definition name. Function values retain the definition in which they were created, so arguments use the caller owner while the function body uses its lexical owner. It is runtime definition context rather than syntax or a standalone-expression magic token.

total # the expression continues after the ignored comment

Punctuation and fixed operators are:

( ) [ ] { } , : . ?. ?? ->
+ - * ** / // % @ << >> & ^ | ~
< <= > >= == != =

Bare = is only record-field, parameter-default, or keyword-argument syntax. It is not an assignment expression. -> is one contiguous token; whitespace may surround it but cannot split it. Canonical source places one space on each side.

Grammar

The following EBNF is explanatory. expression permits low-precedence forms; nonconditional excludes an unparenthesized trailing conditional where the surrounding grammar needs an unambiguous delimiter.

expression       ::= control-form | function-form | raise-form | assert-form
                   | conditional
conditional      ::= coalesce ["if" coalesce "else" conditional]
coalesce         ::= or-expression ["??" coalesce]
or-expression    ::= and-expression {"or" and-expression}
and-expression   ::= not-expression {"and" not-expression}
not-expression   ::= "not" not-expression | comparison
comparison       ::= bit-or {comparison-op bit-or}
comparison-op    ::= "<" | "<=" | ">" | ">=" | "==" | "!="
                   | "in" | "not" "in" | "is" | "is" "not"
bit-or           ::= bit-xor {"|" bit-xor}
bit-xor          ::= bit-and {"^" bit-and}
bit-and          ::= shift {"&" shift}
shift            ::= sum {("<<" | ">>") sum}
sum              ::= product {("+" | "-") product}
product          ::= unary {("*" | "@" | "/" | "//" | "%") unary}
unary            ::= ("+" | "-" | "~") unary | power
power            ::= primary ["**" unary]
primary          ::= atom {attribute | safe-attribute | subscript | call}
record-display   ::= "{" record-field {"," record-field} [","] "}"
record-field     ::= name "=" expression

A top-level comma constructs a tuple. Parentheses group an expression or make the tuple boundary explicit.

Operator precedence

From tightest to loosest:

Level Syntax Associativity / behaviour
Primary ., ?., [], () left chained
Power ** right associative
Unary +x, -x, ~x power binds inside unary
Product *, @, /, //, % left associative
Sum +, - left associative
Shift <<, >> left associative
Bitwise AND/XOR/OR &, ^, | three separate levels
Comparison < <= > >= == != in not in is is not chained, short-circuit
Boolean NOT not recursive prefix
Boolean AND/OR and, then or short-circuit, value preserving
Null coalescing ?? right associative, checks exactly None
Conditional yes if condition else no right associative
Forms ->, raise, assert, try, with complete-expression forms
-2 ** 2 + 3 * 4 << 1
lower <= value < upper and enabled
cached ?? load() ?? fallback
primary if ready else backup if available else none

and and or return operand values, not forced booleans. not returns a boolean. Comparisons evaluate the shared middle operand once. ?? evaluates its right side only when its left value is exactly None; false, zero, and empty collections do not trigger fallback.

Literals

Numbers and constants

Integers support decimal, binary (0b), octal (0o), hexadecimal (0x), and valid underscore separators. Decimal floats support fractional and exponent forms. Complex numbers are not part of V1.

0b1010 + 0o12 + 0xA + 1_000 + 6.25e-2
[true, false, none, True, False, None]

Text, bytes, and raw literals

Single, double, and triple quotes are supported. Prefixes are case-insensitive: r makes raw text, b makes bytes, and br/rb make raw bytes. Ordinary text escapes include common single-character escapes, octal, \xhh, \uhhhh, \Uhhhhhhhh, and named Unicode escapes. Bytes reject Unicode escapes and non-ASCII source characters. Physical newlines require triple quotes.

Adjacent literals of the same kind concatenate during parsing. Text and bytes cannot be mixed.

"hello, " "world" + r"\literal"
b"header" b"\x00"

Formatted strings

f, fr, and rf prefixes work with every text quote style. Use {{ and }} for literal braces. A field contains a non-empty LCL expression, optional debug =, optional !s/!r/!a, and optional format spec. Format specs may contain nested fields. Backslashes and comments are forbidden inside field expression source.

f"user={user.name!r:>12} count={len(items)}"

Primaries and calls

Ordinary attribute access uses the host object's getattr protocol. Safe attribute ?. returns None only when its receiver is exactly None; it does not suppress a missing attribute or descriptor failure.

Record fields use the same ordinary attribute path. {name="Ada"}.name therefore returns "Ada"; a missing field fails normally, including after ?. on a non-null record.

When a Frame contains qualified flat bindings, dotted access traverses lazy Frame proxies. A.B.C.x requests that complete key through the current Frame hierarchy. Safe access through an existing proxy returns None for a missing descendant, so A?.B?.C?.x ?? default is valid. A wholly unknown root A still raises LclNameError.

Subscriptions accept one index, a tuple of indices, or a slice. Calls evaluate the callable first and arguments left-to-right. * expands sync/async iterables; ** requires a mapping with string keys. Positional arguments must precede explicit keyword or keyword-unpack arguments. Explicit keyword names cannot repeat.

user?.profile?.display_name ?? "anonymous"
matrix[1, 2] + items[1:8:2][0]
render(document, *sections, theme="dark", **options)

Every resolver result, operation result, call result, context result, and container child is automatically awaited when it is awaitable. There is no explicit await keyword in LCL.

Collections and comprehensions

Displays use Python-like delimiters:

  • () is the empty tuple; (value,) is a one-item tuple; (value) groups.
  • [] is a list; {} is an empty dictionary; {value} is a set.
  • {name=value} is a non-empty immutable record with named attributes.
  • *iterable unpacks in tuple/list/set displays.
  • **mapping unpacks in dictionary displays.
  • Mixing set and dictionary entries is a syntax error.
("only",)
[0, *prefix, value, *suffix]
{**defaults, "enabled": true, **overrides}
{host=service.host, port=service.port, secure=true}.port

Record fields evaluate eagerly from left to right and every child result is automatically awaited. Names must be ordinary non-reserved identifiers, cannot start with __, and cannot repeat. A trailing comma is accepted. Records are shallowly immutable: their fields cannot change, but a retained list or other mutable host value is not copied or frozen. Record syntax has no empty form, unpacking, or comprehension, and cannot mix with dictionary or set entries.

Generator, list, set, and dictionary comprehensions support repeated for and if clauses. Each target is one identifier; destructuring targets are outside V1. PEP 798-style * heads work for list/set comprehensions and ** heads for dictionary comprehensions.

[value * 2 for value in values if value > 0]
(pair for left in lefts for pair in combine(left) if pair)
{**mapping for mapping in mappings}

The first iterable of a materialized comprehension evaluates eagerly; later iterables, filters, and output are conditional. Generator dependencies are deferred until iteration. Targets bind only after their iterable and remain in scope for later clauses and the head.

Function and control forms

All forms are expressions and return values (or raise). They can appear as a definition value, collection child, call argument, or parenthesized subexpression.

Functions

function-form ::= name "->" expression
                | "(" [parameter {"," parameter} [","]] ")"
                  "->" expression
parameter     ::= name ["=" nonconditional]
                | "*" name
                | name ["=" nonconditional]   # after *name: keyword-only
                | "**" name

Defaults evaluate once when the function value is created. The body uses the defining lexical resolver and captures the same lexical lhs() owner. Calls are async and argument binding supports positional, defaulted, variadic positional, keyword-only-after-*name, and variadic keyword parameters. Recursive re-entry of the same LCL function in one task is prohibited; concurrent calls in different tasks are independent. The bare-name spelling is limited to one required positional parameter. Canonical printing always emits parenthesized signatures, and applying a function form requires grouping, as in ((value) -> value)(1).

(value, scale=2, *extras, clamp=true, **options) -> value * scale

Deliberate failures and assertions

raise(value) raises a source-aware LclEvaluationError; a BaseException value becomes its cause. assert(condition) returns the truthy condition value. assert(condition, message) evaluates the message only on a falsey condition, then raises LclEvaluationError.

raise("configuration is incomplete")
assert(port > 0, f"invalid port: {port}")

Try expressions

try-form ::= "try" ":" expression
             {"except" [nonconditional ["as" name]] ":" expression}
             ["finally" ":" expression]

At least one except or finally is required. Handlers run in source order. A bare handler catches ordinary Exception and must be last. Typed handlers use isinstance against the public error and its cause chain. An as binding exists only in its handler. finally always evaluates and its failure replaces a pending result/failure. Direct BaseException values such as cancellation, KeyboardInterrupt, and SystemExit are not caught by handlers.

try: load() except LclNameError as error: fallback except: none finally: audit()

With expressions

with-form ::= "with" with-item {"," with-item} ":" expression
with-item ::= nonconditional ["as" name]

Managers enter left-to-right and exit right-to-left. Async protocols are preferred; sync protocol results are also auto-awaited. Earlier as bindings are visible to later manager expressions and the body. Truthy exit results suppress the active failure and make the expression return None.

with open_resource() as resource, transaction(resource) as tx: tx.use(resource)

Composite examples

These examples combine multiple grammar families and are parser-checked as a separate composite-complex acceptance set.

(items, *extras, limit=10, **options) -> [*items, *extras][:limit]
try: assert(value > 0, "positive required") except LclEvaluationError as error: fallback(error) finally: audit()
with source() as rows, transaction() as tx: tx.write([transform(row) for row in rows if row?.enabled ?? false])

Evaluation semantics

Evaluation is async-first and left-to-right except documented short circuits, conditional branches, lazy generators/functions, and cleanup unwinding. Host protocols provide arithmetic, comparison, attributes, indexing, calls, iteration, formatting, and context management. Ordinary host Exception failures are wrapped once as source-aware LclEvaluationError; existing LCL errors pass through.

NEED_OVERRIDE and RUNTIME_OVERRIDE are reserved complete-expression declaration markers. They cannot occur inside a collection, call, function body, or other composite expression. Both fail lazily when requested without a value. Their replacement and host-input rules are defined in the runtime reference.

Failures render complete multiline English diagnostics with original source, definition and function frames, and already-read value snapshots. Rendering does not evaluate additional names. Exceptions retain their types, stable codes, original causes, and Python tracebacks. See errors and inspection.

Runtime Frames add lazy per-name success/failure caching, one owner task per name, cycle detection, waiter cancellation isolation, task-local resource limits, explicit atomic recalculation, dependency snapshots, and deterministic close. Recalculation never invalidates dependants. LCL is trusted configuration, not a security sandbox: host values and callables may have ordinary Python side effects or blocking behaviour.

Rainy cases and exceptions

The marked examples below are deliberately malformed and are continuously checked to raise LclSyntaxError with a source span.

1 +
[1, 2
{"set", "key": 2}
{a=1, a=2}
{a=1, "b": 2}
[**mapping]
call(option=1, 2)
(x=1, y) -> x + y
(x, x) -> x
raise "boom"
assert(true
try: value
try: value except: 0 except Error: 1
with manager as: value
f"{}"
"text" b"bytes"

Runtime rainy cases use structured public exceptions:

Situation Exception / behaviour
Unknown resolver name LclNameError with requesting span
Unsupported host operation/protocol failure wrapped LclEvaluationError with cause
raise or failed assert source-aware LclEvaluationError
Frame dependency cycle LclCircularDependencyError with ordered path
Evaluation budget exceeded LclEvaluationError at offending AST span
Request after close begins LclClosedFrameError
Direct cancellation/BaseException propagates unwrapped after required cleanup
Missing ordinary attribute after ?. on non-None normal failure; not suppressed
Duplicate ** keyword at runtime LclEvaluationError with the duplicate-keyword code

Unsupported Python syntax

V1 deliberately excludes statements and Python compilation semantics:

  • assignment statements, annotated assignment, :=, and augmented assignment;
  • the Python lambda keyword, async def, explicit await, yield, and yield from;
  • import, from, class, match, loops, statement if, and statement try;
  • list/set/dict target destructuring and direct same-function re-entry;
  • complex literals, template strings, and bytes f-strings;
  • arbitrary code loading, Python AST injection, and implicit module access.

Use LCL's (...) -> expression, eta-expanded fixed-point combinators when recursion is genuinely useful, expression-form try/with, host-provided reviewed Presets, and explicit application APIs instead.

See errors and diagnostic codes for exception fields, specific codes, cause chains, and executable troubleshooting examples. Ordinary library validation uses LclValidationError; native callback and operation failures retain their original exception in __cause__.

The exception guide describes each failure family, grouped execution and cleanup failures, actual diagnostic output, and application handling with except* and native control signals.