Reader and layout syntax
Normative rules for how the Koskari layout reader turns source text into form trees. For motivation and the relationship between layout and s-expressions, see Koskari and s-expressions.
Overview
The layout reader (LayoutReader) is the default reader for Koskari
modules. It accepts indentation-based blocks and lines at delimiter
depth 0, embeds ordinary s-expressions inside (, [, and {, and
parses colon blocks as #block: conses and expands them through
layout handlers (layout/<head>) into list forms for the compiler.
Reading is strict: malformed layout or punctuation fails before evaluation.
Layout mode and s-expression mode
Layout mode applies at delimiter depth 0 – outside all (, [, and
{. A module file or REPL line at the top level uses layout rules for blocks
and lines.
S-expression mode applies inside any open delimiter. Forms nested in parentheses, brackets, or braces follow the base reader: atoms, lists, keywords as in s-expression mode, and no indent-sensitive block detection.
def sign [n]:
if (< n 0):
"negative"
else:
"positive"
The outer def block uses layout mode. The condition (< n 0) inside the
if header uses s-expression mode within the header argument.
Layout-block detection is suppressed inside delimiters regardless of
physical indentation. A : inside nested delimiters is never the structural
block opener for the enclosing layout header.
Body forms are read recursively: each body line is either a layout line, a new sub-block, or an s-expression form, processed through the same layout reader.
Logical lines and delimiter depth
The reader tracks delimiter depth while scanning. Encountering (, [, or
{ increases depth; ), ], or } decreases it.
A physical newline ends a logical line only when delimiter depth is 0. Inside an unclosed delimiter, a newline is treated as whitespace and does not end the header or layout-line scan.
This allows multi-line header arguments and expressions:
let [[a 1000]
[b 2000]]:
(+ a b)
The header is one logical line; the body begins after the structural : and
the following newline.
Colon in layout mode
At delimiter depth 0, : is a terminating character for header and
layout-line scans. The final : on a header line – after which only
whitespace and/or a comment remain until the logical line ends – is
structural: it opens the block body. It is not part of header content.
Spacing around the structural colon does not matter: if x: and if x : are
equivalent.
Keywords such as as:, x:, and else: in header arguments remain intact
when followed by more header content. Only the colon that terminates the header
is structural:
foo x: 1 y: 2:
(+ x y)
Here x: and y: are keyword atoms in the header; the last : ends the header.
Bare keyword atoms are valid header arguments at depth 0. A keyword that is the
last header argument needs a double colon so the structural terminator
remains distinct from the keyword’s own trailing ::
unknown as: x:
y
;; -> (unknown as: x y)
unknown x::
z
;; -> (unknown x: z)
Wrapping a keyword in a delimiter ([...] or (...)) is also valid – inside
delimiters, s-expression mode applies – but wrapping is not required for
keywords in header arguments.
Header-colon recognition is a single forward pass: a predicated : event
fires only when the remainder of the logical line is whitespace and/or a
comment. Peer gathering and quote/quasiquote predicates may speculate on the
stream separately; that is not a replay of the header scan.
Layout blocks
A layout block consists of:
Header – one or more forms read until the structural
:Structural colon – the final
:on the header logical lineNewline – required; the body does not start on the same line as the header
Body – forms at an indent strictly greater than the header line, until dedent or end of file
The first header form is the head; remaining forms before the colon are args. The head may be any form (symbol, list, etc.), not only a bare symbol.
The first body line sets the body’s indent, and every later content line of
that body must start at the same column; a top-level form starts at column 0.
A deeper line is unexpected indent, and a shallower line that is still inside
the block is unindent does not match the enclosing block. Both are
ReaderSyntaxError under the default reader/indent pragma; warn and
ignore read the line as a sibling form instead (see
Pragmas). Blank lines and comment-only lines are never
measured, and a line inside an open delimiter is a continuation, not a body
line.
if x:
y
Sub-blocks may appear in the body. Each is parsed as a #block: cons.
Peer heads declared for the sub-block’s layout (for example else after nested
if) are gathered on the same spine and expanded together when the enclosing
block is expanded.
Invalid block shape
A header with no forms before the structural colon is an error. Content after
: on the same physical line means that colon is not structural (the
remainder is neither empty nor a comment), so the line is read as a layout
line – often with a keyword atom – rather than as a layout block. A structural
colon therefore requires only whitespace and/or a comment to end of line, then
a newline before the indented body.
Peer blocks
Some layout handlers declare peer head symbols (for example elif and
else for layout/if). After the primary block body,
the reader checks the next content at the same outer indent as the primary
header. If the line begins a block whose head is a declared peer, that block is
consumed as a peer. Peer blocks are expanded together with the primary block
through apply_layouts.
Peers are separate blocks: each peer has its own header, structural :, newline,
and indented body.
if x:
a
elif y:
b
else:
c
Blank lines and comment-only lines between a primary body and a peer header do not prevent peer gathering.
Peer acceptance is defined per handler: only heads listed in the handler’s peer
set are gathered. Syntax errors such as elif after else are rejected by
the handler (see layout/if).
At the grammar level, peers are ordinary BlockLine nodes; special meaning is
entirely in the active layout handler.
Layout lines
A layout line is a depth-0 logical line that does not end with a
structural block :. The reader collects the elements on the line until
LINE_END or EOF and keeps them as the line; the fold below is applied by
the layout handler that receives the line, or by read() for a top-level
line, through the reader’s fold_line.
Elements on line |
Form (implicit calls enabled) |
|---|---|
One element |
That element |
Several elements |
|
Examples:
foo bar
;; -> (foo bar)
(get-callee) a b
;; -> ((get-callee) a b)
if True 1 2
;; -> (if True 1 2)
The first form may be any value – symbol, number, string, or parenthesized form – not only a bare symbol.
When layout_implicit_calls is false, a line with more than one form is a
syntax error (multiple forms on one layout line).
Layout lines are distinct from layout blocks: a line like if test then else
does not use peer blocks and cannot express elif chains.
Block representation and layout expansion
Parsing and layout expansion are separate steps.
Parse
A colon block is read as a #block: cons: the header’s elements as one
list, then one entry per body line, each entry either a line, a #line: cons
over the elements the reader saw on it, or a nested block:
(#block: (HEAD ARG ...) ENTRY ...)
ENTRY ::= LINE | BLOCK
LINE ::= (#line: ELEMENT ...)
Nothing on a line is folded at parse time. else: x is a two-element line,
(else: x) is a one-element line holding a pair, and a nested block is its
own entry, so the handler that expands the block can tell them apart:
foo:
else: x
bar:
y
(else: x)
;; (#block: (foo) (#line: else: x) (#block: (bar) (#line: y)) (#line: (else: x)))
Stream-collected peer blocks (when the layout declares peer heads) form a spine
of #block: conses that are expanded as one unit.
Block quote and quasiquote prefixes ('head: and `head:, and their
mark-keeping forms #'head: and #`head:) read raw #block: conses in
the header and body without expanding nested layout. The prefix opens a
block only as the first element of its line; anywhere else on a line it
quotes the next form, and the line’s colon belongs to the line’s own head,
so case 'north: is a case block over the quoted symbol. They are wrapped
for
layout/quote, layout/quasiquote, layout/quote-syntax, and
layout/quasiquote-syntax to lower at expansion time.
Expand
LayoutReader.read() calls apply_layout (one block) or
apply_layouts (primary plus peers on a spine) from
koskari.reader.layouts, passing itself. Each lookup resolves
layout/<head> in the module namespace, then builtins. Handlers are
invoked as read(reader, source, peers), where reader is the
LayoutReader expanding the source and source is
(header . entries), the cdr of the block cons, with header =
(head . args).
The line rule lives on the reader: reader.fold_line(entry) returns a
one-element line’s element, folds several elements to a call, or raises when
reader/implicit-calls is off, and lowers a block entry to its form;
reader.fold_lines(entries) does the same for a whole body. Before a
handler runs, every nested block whose head is not a keyword has already been
lowered into a one-element line, with its peers gathered from the entries
after it; a block whose head is a keyword is left for the handler. A handler
folds where it wants forms and reads the elements directly where it does not,
so the default layout/block can splice a
keyword line or a keyword-headed block as a keyword argument while leaving a
written (else: x) pair intact.
Expansion that starts from a namespace rather than a reader, as the runtime
apply-layouts builtin does for a filled quasiquote block, resolves the
module’s reader through its bound __reader__, rebuilding one from
__pragma__ when the load-time facet was detached.
When no named layout matches, layout/block splices body forms after the
header:
foobar [x y z]:
(print x y z)
;; -> (foobar [x y z] (print x y z))
For an explicit compile/begin wrapper, bind
var layout/<head> layout/block-begin in a .ki module. See
layout/block-begin.
See Layout handler reference for the per-head catalogue.
Pragmas
LayoutReader exposes three switches:
- layout_enabled
Default true. When false, the reader delegates to the inner s-expression reader and layout block/line rules do not apply.
- layout_implicit_calls
Default true. When false, a layout line with more than one top-level form is an error instead of folding to an implicit call.
- layout_indent
An
IndentMismatchvalue, defaultERROR: what a content line whose indent does not match its block does.WARNemits aSyntaxWarningand reads the line as a sibling form;IGNOREreads it as a sibling silently.
Module pragma objects may supply these via reload_pragma on the reader: the
pragma keys are reader/layout, reader/implicit-calls, and
reader/indent (they map to layout_enabled, layout_implicit_calls,
and layout_indent). These switches are intended for tooling and
pragma-driven reader configuration.
reload_pragma carries the suffix-syntax switches too – reader/index-brackets,
reader/dot-attrs, and reader/string-segments, described under
Suffix syntax.
One suffix interacts with layout. A string segment’s gap crosses newlines, so a
continuation line is consumed as part of the value on the line that opened it,
and the block structure is unaffected. The gap skip stops at a dedent, so a
continuation line may not sit at column 0 inside a layout block – that column
ends the logical line before the gap can reach the segment, and the reader
reports string segment must stay indented within its block. Any indentation
satisfies this, including less than the block’s own body indent.
Formal grammar
The grammar below describes surface syntax at layout depth 0. Layout head resolution and peer sets are the operational rules stated earlier on this page, not encoded in the EBNF productions.
Terminals
ATOM– lexer atom (symbol, number, string, keyword token, etc.)SEXPR– balanced delimited form parsed by the s-expression reader as one unit:– structural header terminator at depth 0NL– logical line break (physical newline at delimiter depth 0)INDENT,DEDENT– indentation boundaries
Productions
Module ::= Suite EOF ;
Suite ::= { Line }* ;
Line ::= BlankLine
| CommentLine
| BlockLine
| ExprLine ;
BlankLine ::= NL ;
CommentLine ::= NL ; (* comment elided or skipped *)
BlockLine ::= Header ':' NL INDENT Suite DEDENT ;
Header ::= HeaderPart { HeaderPart } ;
HeaderPart ::= ATOM | SEXPR ;
ExprLine ::= Expr NL ;
Expr ::= SEXPR
| LayoutExpr ;
LayoutExpr ::= ExprPart { ExprPart } ;
ExprPart ::= ATOM | SEXPR ;
Operational rules (not in EBNF)
Embedded s-expressions: When a
HeaderPartorExprPartbegins with(,[, or{, the s-expression reader consumes a complete balanced form as oneSEXPR. Newlines inside that form do not end the outer logical line.Layout head resolution: For
BlockLinewith header partsH0 H1 ..., expansion resolveslayout/<H0>whenH0is a symbol; otherwiselayout/blockapplies.Peer blocks: Peers are
BlockLinenodes at the same indent as the primary header, gathered only when the active handler declares their head symbols, then expanded with the primary throughapply_layouts.Handler inputs:
source=(header . entries);peers= list of(pheader . pentries)pairs (peer headers and bodies before folding).
Conforming examples
let [[x 1] [y 2]]:
(+ x y)
let [[a 1000]
[b 2000]]:
(+ a b)
if (& a 1):
'foo
else:
'bar
See also
Koskari and s-expressions – the rules beside their s-expressions
Layout handler reference – per-head lowering (
layout/if, …)Grammar – syntax hub