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.*iterableunpacks in tuple/list/set displays.**mappingunpacks 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
lambdakeyword,async def, explicitawait,yield, andyield from; import,from,class,match, loops, statementif, and statementtry;- 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.