# Markup Syntax Specification v0.1
> **⚠ Experimental (v0.x).** The language and its tooling are still evolving;
> expect breaking changes to syntax and APIs as the project matures toward 1.0.
---
## Why Markup Exists
Markup started from one question: **what if HTML looked like functions instead
of angle brackets?**
```html
<form class="login" method="post" action="/sign-in">
<fieldset>
<legend>Account</legend>
<label for="user">Username</label>
<input id="user" type="text" required>
<button type="submit" disabled>Sign in</button>
</fieldset>
</form>
```
```mu
form(class="login", method="post", action="/sign-in"
fieldset(
legend("Account")
label(for="user", "Username")
input(id="user", type="text", required)
button(type="submit", disabled, "Sign in")
)
)
```
Following that idea led to a small general notation rather than a document
format: a node is a parenthesized body `( … )` with an optional tag in front
of it - `()` and `button()` are both nodes - and a body holds values,
`key=value` parameters, and more nodes. In HTML-like trees, put keyed
parameters (attributes) before text content and child nodes - as in the
button and label lines above. Nothing in the language knows about documents,
styles, or servers - which is why the same few rules can describe DOM-like
trees, scene graphs, stylesheets, animations, and plain configuration, while
staying simple enough to read at a glance.
Nodes read as function calls, so there is no new shape to learn; `{…}`
objects and `[…]` lists are first-class values that nest freely with nodes; a
node is itself a value; and tagged multiline strings give heredoc power with
zero escape processing.
---
## Overview
Markup (`.mu`) is a UTF-8 general-purpose markup language - an alternative to
HTML and XML for structured content such as documents, layouts, and scene
graphs. Because the root node accepts `key=value` params, objects, and lists
directly (`app="demo"`, `settings={host="localhost"}`), the same notation
doubles as a configuration language with no extra ceremony. It is built from
a few pieces:
- **Values** - literals such as strings, numbers, booleans, null, lists, and objects.
- **Keyed parameters** - a name, `=`, and a value (`name=value`), including a short form for `name=true`.
- **Nodes** - optional tag plus `( … )` containing more values, parameters, and child nodes.
- **Comments** - `//` line comments and `/* … */` block comments.
A file is one untagged **root node** (no tag). Top-level lines are that node's **body**: the same kinds of items allowed inside `( … )`, without wrapping the file in parentheses. The sections below introduce each piece in order, starting with comments and values.
---
## Source Text
A few rules apply to the raw file, before any syntax:
- A `.mu` file is UTF-8. An invalid UTF-8 byte sequence is an error.
- A leading byte-order mark is accepted and skipped (linters warn).
- Line endings are normalized CRLF → LF before any other processing -
including inside raw multiline strings. A lone carriage return that is not
part of a CRLF pair is an error.
- After normalization, a raw control character other than newline or tab is
an error anywhere in the file, even inside multiline strings. Strings can
represent control characters with escapes (introduced below).
- Outside strings and comments, the only whitespace characters are space and
tab. Anything else (no-break space, form feed, …) is an error.
---
## File Shape And Comments
Top-level lines are the root node body.
```mu
// A line comment starts with two slashes.
/*
A block comment can span multiple lines.
Block comments do not nest.
*/
/* Short block comments work too. */
```
### The `@mu` directive
All tool configuration lives in one directive namespace: a comment whose body
begins with `@mu` followed by `key=value` settings configures tools for the
whole file. Parsers treat the comment as an ordinary comment, and unknown
keys are ignored, so older tools skip newer settings.
```mu
// @mu version=0.1
// @mu suppress=comma
/* @mu
width=100
commas=off
inline-nodes=off
*/
```
When an `@mu` directive **comment begins on the first line** of a file and
carries a `version` key (either `// @mu version=…` on line 1, or a
`/* @mu` block that opens on line 1 with `version=` on any line of that
block), tools surface that value as the file's declared syntax version.
(0.1 is currently the only version; the key is a placeholder until a future
revision needs it.) A `version` in a directive that starts after line 1 is
**W020**.
Recognized keys include linter keys such as `suppress=` (comma-separated
codes or aliases such as `comma` for **W007** and **W026**), formatter keys
(`width`, `indent`, `pack`, `blanks`, `kv`, `commas`), and language keys
(`version`, `imports`, `inline`, and the scoped `inline-*` keys). Explicit
tool flags override file directives. See
[`linter.md` section 2.4](linter.md#24-file-directives-mu) and
[`formatter.md` section 5.1](formatter.md#51-file-directives-mu).
The block-comment form configures the same keys as the line form; settings
separate by commas or newlines. Later directives override earlier ones per
key.
---
## String Values
A string value can appear by itself at the top level.
```mu
"Markup" // double-quoted string value
'Markup' // single-quoted string value
"It's fine" // apostrophe inside double quotes; no backslash needed
'Say "hello"' // double quote inside single quotes; no backslash needed
"Say \"hello\"" // same text with double-quote delimiters; escape inner "
'It\'s fine' // apostrophe inside single quotes; escape with \'
"C:\\Users\\alice\\doc.txt" // backslash is escaped as \\
"hello\nworld" // \n represents a newline character inside the string
"a\tb" // \t represents a tab character
"a\rb" // \r represents a carriage return character
"snowman \u{2603}" // \u{…} is a Unicode scalar value, 1–6 hex digits
"\u{1F600}" // any scalar value works, including outside the BMP
"" // empty double-quoted string
'' // empty single-quoted string
/*
String content may contain UTF-8 text.
*/
"Sao Paulo"
```
The escape set is closed: `\"` `\'` `\\` `\n` `\t` `\r` `\u{…}` and nothing
else. `\u{…}` takes 1–6 hex digits and must name a Unicode scalar value -
surrogates and values above `0x10FFFF` are errors.
```mu-err
"club \q diamond" // error: E011 - unknown escape sequence \q
```
```mu-err
"\u{D800}" // error: E011 - \u{…} must be a Unicode scalar value, not a surrogate
```
```mu-err
"line one
line two" // error: E002 - raw newline inside string — use \n escape or multiline form
```
---
## Number Values
Numbers can appear by themselves at the top level.
```mu
+99 // plus-signed integer
42 // decimal integer
0 // zero
-17 // negative integer
1_000 // underscores between digits
5_349_221 // grouped decimal integer
0xDEADBEEF // hexadecimal integer; prefix is lowercase, digits are any case
0xdead_beef // hexadecimal with underscores
0x00FF // radix digits may be zero-padded - the leading-zero rule is decimal-only
0o755 // octal integer
0b11010110 // binary integer
+1.0 // plus-signed float
3.1415 // decimal float
-0.01 // negative float
5e+22 // exponent with plus sign
1e06 // exponent digits may be zero-padded
-2E-2 // uppercase E exponent
6.626e-34 // negative exponent
224_617.445_991_228 // underscores in integer and fraction parts
inf // positive infinity
+inf // explicit positive infinity
-inf // negative infinity
nan // not-a-number
+nan // explicit positive not-a-number
-nan // negative not-a-number
```
Numbers follow TOML: a leading `+` or `-` applies to decimal integers, floats,
`inf`, and `nan` only. Hex, octal, and binary literals are unsigned.
Integers and floats are distinct types. Implementations must support at least
64-bit signed integers and IEEE 754 double-precision floats. Integer literals
beyond ±2⁵³ are legal; linters warn where doubles would lose precision. A
literal beyond the implementation's integer range must produce a clean
error - never a silent wrap, truncation, or conversion.
```mu-err
1. // error: E008 - decimal point requires digits on both sides
.5 // error: E008 - digits are required on both sides of the decimal point
042 // error: E008 - invalid leading zero in decimal integer
0XFF // error: E008 - radix prefixes are lowercase only (0x, 0o, 0b)
1_.5 // error: E008 - invalid underscore placement in number
1e_5 // error: E008 - invalid exponent in number
-0xFF // error: E008 - hex, octal, and binary literals take no sign
+0b101 // error: E008 - hex, octal, and binary literals take no sign
```
---
## Boolean And Null Values
`true`, `false`, and `null` are exact-lowercase literal values - so are `inf`
and `nan`. Case variants (`True`, `NULL`, `Inf`) are ordinary identifiers,
never values.
```mu
true // boolean true value
false // boolean false value
null // null value
```
---
## Keys And Keyed Parameters
A keyed parameter is `key=value`. The key is either an **identifier** or a
**single-line string** (`"…"` or `'…'`) immediately followed by `=`. The
value sits on the right. Boolean shorthand (`debug` ≡ `debug=true`) uses an
identifier only - a quoted string alone is never shorthand.
```mu
// @mu suppress=I004
name="Markup" // identifier key: letters at the start
pool_size=10 // identifier key: underscore in the name
pool-size=10 // identifier key: hyphen in the name
size2=8 // identifier key: digits after the first character
a--b=2 // identifier key: internal double hyphen is legal
_internal=0 // identifier key: leading underscore
True=1 // case-sensitive: True is a name; true is a value
"this is a key"="this is a value" // string key: any single-line string
'retry-count'=10 // string key: single-quoted form
"Content-Type"="text/plain" // string key: reserved-looking text is fine
"true"=1 // string key: reserved words as keys are legal when quoted
port=8080 // number value on the right
tls=true // boolean value on the right
missing=null // null value on the right
limit=inf // special number value on the right
debug // boolean shorthand, equivalent to debug=true
```
When a single-line string is **not** immediately followed by `=`, it is a
positional value - not a key and not boolean shorthand:
```mu
"hello" // positional string at the top level
card("title") // positional string inside a node body
```
### Identifier keys
Identifiers are ASCII. The first character is a letter or `_`; the rest are
letters, digits, `_`, and `-`; the last character may not be `-`:
```text
identifier = [A-Za-z_] ( [A-Za-z0-9_-]* [A-Za-z0-9_] )?
```
The identifier rule applies everywhere a **name** appears: identifier keys,
node tags, boolean shorthand, and multiline string delimiter tags (all
introduced below). Object field keys and node param keys may also use string
keys (above); tags and shorthand remain identifier-only.
```mu-err
2fast=1 // error: E017 - missing separator between adjacent same-line items
-speed=1 // error: E008 - special values must be exactly `inf`, `+inf`, `-inf`, `nan`, `+nan`, or `-nan`
speed-=1 // error: E012 - identifier cannot end with a hyphen
café=1 // error: E012 - non-ASCII character in identifier — use ASCII names
```
The words `true`, `false`, `null`, `inf`, and `nan` are reserved as values.
They are not valid **identifiers** - the same list as above: identifier keys,
tags, boolean shorthand, delimiter tags. A string key may spell any text,
including reserved words (`"true"=1` is legal; bare `true=1` is not).
```mu-err
true=1 // error: E018 - reserved word cannot be used as an identifier
""=1 // error: E007 - empty string is not a valid key
''=1 // error: E007 - empty string is not a valid key
```
An unquoted word is never a string, and wrong-case literals are not values:
```mu-err
color=red // error: E010 - unknown word — add double quotes if this is meant to be a string
tls=True // error: E009 - `True` is not valid — use lowercase `true`
```
Within one scope, a key may appear only once. Quoting does not create a
distinct key: a string key and an identifier key collide when their decoded
text matches, so every key addresses exactly one slot in the scope's
key/value map:
```mu-err
port=80
port=90 // error: E004 - duplicate key in the same scope
"a"=1
a=2 // error: E004 - duplicate key; "a" and a spell the same key
```
---
## Multiline String Values
Use `"""` for indent-aware multiline strings and `'''` for verbatim multiline
strings. **Neither form processes escapes** - a backslash is an ordinary
character. The value is the interior lines joined with newlines; the newline
before the closing delimiter is not part of the value.
```mu
// @mu suppress=W001
greeting="""
hello
world
""" // value is two lines: hello, world - no trailing newline
path_help="""
Backslashes are literal here: C:\Users\alice
""" // no escape processing inside multiline strings
script='''
#!/bin/bash
echo "hello"
''' // verbatim ''' preserves content exactly: scripts, logs, SQL
""" // only a // or same-line /* … */ comment may follow the opening delimiter
HELP!!
I NEED HELP!!!!
"""
```
### Opening delimiter lines
After the opening delimiter - quotes plus an optional tag - the rest of that
line may contain only optional whitespace and optional comments. A
`//` line comment, a `/* … */` block comment that closes on the same line, or
a block comment followed by a line comment is not string content; anything
else on the line is **E020**. Whitespace after the delimiter when the line
has no comment is accepted but non-canonical (**W029**).
```mu
// @mu suppress=W001,W028
note=""" // line comment on the opener - not content
inner content
"""
body=''' // line comment on the opener - not content
inner content
'''
asd1="""mu // has comment - no W029
inner content
"""mu
asd2="""mu // whitespace before the comment is fine - no W029
inner content
"""mu
doc="""mu // allowed
inner content
"""mu
```
```mu-err
bad=""" hello // error: E020 - non-comment content after opening `"""`
content
"""
```
```mu
// @mu suppress=W001,W028
// W029: whitespace after multiline opening delimiter with no comment on the line
loose="""mu
inner content
"""mu
```
(The opener line ends with a real trailing space; linters underline it.)
Both delimiters live on their own lines - a multiline string cannot open and
close on one line, and content cannot share a line with a delimiter:
```mu-err
banner="""all on one line""" // error: E021 - multiline delimiter not alone on its line — open and close on one line
```
```mu-err
banner="""
all but the close
on one line""" // error: E021 - content shares a line with the closing multiline delimiter
```
### Indent-aware dedent
`"""` lets you indent the string in source without keeping that margin in the
value. The **anchor** is the smaller of the two delimiters' offsets - the
count of characters left of the first quote, measured from column 0, so a
`key=` prefix counts. Every non-blank interior line drops exactly that many
leading whitespace characters (a tab counts as 1 character; linters warn on
mixed indentation).
```mu
"""
hello
world
"""
item="""
This is cool
""" // 2 chars left of this delimiter vs 5 for the opener - the anchor is 2
poem="""
one
two
""" // blank lines need no indent; each contributes one empty line
```
A non-blank interior line that starts left of the anchor is a hard error -
the diagnostic must report the triggering line, the anchor column, and how
many characters of indentation are missing:
```mu-err
message="""
Normal content.
Short line.
Back.
"""
```
The value is the interior lines joined with a newline **between** each pair -
newlines are separators, not line terminators. A single line of text has no
`\n` in it, so one interior line contributes just its own text and N interior
lines join with N−1 newlines. That makes the edge cases fall out naturally:
`a` has zero interior lines, so there is nothing to join - empty string. `b`
has one interior line whose text is empty; one line means no separator, so
the value is that empty text - still the empty string. Only `c`, with two
empty interior lines, has a pair to join - the value is the single `\n`
between them.
```mu
// @mu suppress=W001
a="""
""" // zero interior lines → empty string
b="""
""" // one blank interior line → empty string as well
c="""
""" // two blank interior lines → a single newline
```
### Tagged delimiters
A delimiter is exactly three quotes plus an optional **tag** (identifier
rules, no space between the quotes and the tag). The tag abuts the quote run
the same way a node tag abuts `(`. The closing delimiter must match the
opener character for character - same quotes, same tag - and may be followed
only by a comma and/or a `//` comment. An interior line that begins with the
matching closing delimiter, followed by nothing except that optional tail,
closes the string; every other interior line is plain content, so a tagged
delimiter can wrap text that itself contains `"""`.
Opening-line tails follow [Opening delimiter lines](#opening-delimiter-lines):
`"""mu //note`, `"""mu /* note */`, and `'''sh /* sh */` are valid; bare
whitespace after the tag with no comment warns (**W029**).
```mu
// @mu suppress=W001,W028
doc="""mu
a="""
hello
"""
"""mu // closes the outer string; the inner """ lines are plain content
shell='''sh
echo '''
'''sh // tags work on both forms
```
The untagged form cannot contain a line consisting of exactly its own
delimiter - add any tag. If the content also contains `"""mu`, pick another
tag (`"""mu2`). This is the heredoc rule: there is always an out, and never
an escape sequence.
```mu-err
x=""""
oops
"""" // error: E016 - ambiguous quote count (4 consecutive `"`)
```
```mu-err
y="""true
nope
"""true // error: E018 - reserved word cannot be used as a multiline delimiter tag
```
---
## Nodes
A node is a parenthesized scope. It is **untagged** when there is no
identifier before `(`, or **tagged** when an identifier is immediately
followed by `(` - nothing between them, not even a comment.
```mu
() // empty untagged node
debug() // empty tagged node; the tag touches "("
card(title="Docs") // tagged node; tag card
server(
host="localhost" // keyed parameter inside a node body
port=8080
tls
health_check() // child node inside a node body
health_check() // duplicate child TAGS are fine - children form an ordered list
)
verbose, () // a separator makes two items: shorthand verbose=true, then an untagged node
/*
Node bodies can contain keyed parameters, positional values, and child nodes.
Their order is preserved. Duplicate child tags are legal; duplicate KEYS in
one scope are not (see Identifiers).
*/
```
A **tagged** node requires the tag to abut `(` with no whitespace. Whitespace
before `(` separates items - boolean shorthand plus an untagged node (**W027**):
```mu
debug () // W027: boolean shorthand followed by an untagged node — write "debug()" if a tagged node was intended
verbose, () // comma also separates: verbose=true, then untagged ()
```
```mu-err
debug/* gap */() // error: E023 - comment between node tag and `(`
```
---
## Positional Values
A positional value is a value without a key in a scope.
```mu
"localhost" // positional string at the top level
8080 // positional number at the top level
true // positional boolean at the top level
null // positional null at the top level
connect(
"localhost" // positional string inside a node body
8080 // positional number inside a node body
true // positional boolean inside a node body
)
job(
"ingest" // positional value
retries=3 // keyed parameter
dry_run
notify(email="ops@example.com") // child node
"second" // positionals, params, and nodes interleave freely; order is preserved
)
```
---
## List Values
A list is a value wrapped in `[` and `]`. List elements can be any value.
```mu
// @mu suppress=W026
[1, 2, 3] // list of number values
[true, false, null] // list of boolean and null values
["web", 8080, true, null] // mixed primitive values
numbers=[1, 2, 3] // keyed parameter whose value is a list
nothing=[] // an empty list is legal
cards=[
card(title="Slots") // list element can be a node value
card(title="Live")
()
]
matrix=[
[1, 2] // list element can be another list
[3, 4]
]
quotes=[
"""
first
""", // a comma may follow a closing multiline delimiter
"""
second
"""
]
```
Lists hold **values only** - no keyed parameters, no boolean shorthand - and
a tag binds only to `(`:
```mu-err
[debug] // error: E010 - unknown word — add double quotes if this is meant to be a string
[a=1] // error: E010 - unknown word — lists hold values, not key=value pairs
card[1] // error: E017 - missing separator between adjacent same-line items — a tag binds only to `(`
```
---
## Object Values
An object is a value wrapped in `{` and `}`. Object entries are explicit
`key=value` fields. Keys may be identifiers or single-line strings (same rules
as keyed parameters).
```mu
{host="localhost", port=8080, tls=true} // object value at the top level
config={host="localhost", port=8080, tls=true} // keyed parameter with object value
blank={} // an empty object is legal
headers={"Content-Type"="application/json", "X-Custom"="yes"} // string keys
service={
name="api" // object field
enabled=true // object field
tags=["web", "v2"] // object field with list value
endpoint=server(host="localhost", port=8080) // object field with node value
metadata={owner="ops", tier=1} // object field with nested object value
}
/*
Object bodies use explicit key=value fields only.
*/
```
```mu-err
{debug} // error: E007 - object entries must be `key=value` fields
{"only-a-value"} // error: E007 - positional values are not valid inside objects
{a=1, a=2} // error: E004 - duplicate key `a`
card{a=1} // error: E017 - missing separator between adjacent same-line items — a tag binds only to `(`
```
---
## Whitespace And Separators
Same-line items may be separated by a **comma** or by **whitespace** (one or
more spaces or tabs). Line-separated items omit both - the newline is the
separator.
```mu
server(host="localhost", port=8080, tls=true) // commas are always fine
server(host="localhost" port=8080 tls) // whitespace also separates same-line items
server2(
host="localhost" // line-separated items need no comma or extra whitespace
port=8080
tls=true
)
tags_inline=["web", "api", "v2"] // commas between same-line list values
tags_spaced=["web" "api" "v2"] // whitespace between same-line list values
tags_block=[
"web" // line-separated list items need no commas
"api"
"v2"
]
config_inline={host="localhost", port=8080} // commas between same-line fields
config_spaced={host="localhost" port=8080} // whitespace between same-line fields
config_block={
host="localhost" // line-separated object fields need no commas
port=8080
tls=true
}
server3(
host="localhost", // trailing commas are accepted (linters flag them)
)
server4(
host="localhost", // a comma before a newline is legal but non-canonical
port=8080
)
```
```mu
server(8080 true) // ok - whitespace separates positional values
```
```mu-err
server(8080true) // error: E017 - missing separator between adjacent same-line items
```
```mu-err
server(host="a",, port=1) // error: E005 - consecutive commas create an empty slot
```
```mu-err
server(, host="a") // error: E005 - consecutive commas (a leading comma reads as an empty slot)
```
The key, the `=`, and the **start** of the value share a line. A value that
opens a bracket - `(`, `[`, `{`, `"""`, `'''` - may then span lines.
Whitespace around `=` is allowed (linters note it; the formatter removes it):
```mu
spaced = 1 // legal; canonical form is spaced=1
potato=( // the value STARTS on this line, then the bracket spans lines
1
)
```
```mu-err
port=
5 // error: E022 - value must start on the same line as `=`
```
Comments may sit between items and around commas, but a block comment may not
separate a key from its `=`, or the `=` from its value:
```mu
// @mu suppress=W026
server(
host="localhost", // comments around commas and between items are fine
/* a block comment between items is fine too */
port=8080
)
```
```mu-err
port=/* gap */8080 // error: E023 - block comment between `=` and its value
```
---
## Imports And Spreads
Markup can pull structured content from other `.mu` files into a scope.
Each file stays a separate document on disk; tools read dependencies when
needed and **insert** the other file's root items - params, values, and child
nodes - at spread sites. Linking merges **parsed structure**, not source text.
There are four surface forms. **Import directives** name a file; **spreads**
reference a prior binding and trigger insertion.
| Form | Role |
|---|---|
| `@@"<path>"` | Import spread - load and insert in one step (**no alias**) |
| `@"<path>"` | Import declaration - register a file for later insertion |
| `@"<path>"<alias>` | Import declaration with an **explicit** alias (alias abuts `"`) |
| `@<alias>` | Spread - insert via a prior `@"` binding |
**Alias attachment** (normative):
| Source text | Parsed as |
|---|---|
| `@"path"` | `import_decl` with implicit alias from path stem (when stem is a valid identifier) |
| `@"path"alias` | `import_decl` with **explicit** alias (`alias` abuts `"`) |
| `@"path" id` | `import_decl` (implicit alias) **then** boolean shorthand `id=true` - whitespace ends the import token |
| `@@"path"` | `import_spread` only |
| `@@"path"id` | `import_spread` + **E034** (abutting alias after `@@` is forbidden) |
| `@@"path" id` | `import_spread` **then** boolean shorthand `id=true` |
Only `@"…"` may bind an explicit alias, and only in the abutting form.
`@@"…"` never registers a name. Retired **E028** - whitespace after `@"path"`
does not error; it begins the next body item ([`linter.md` section 8](linter.md#8-retired-and-redefined-codes)).
Paths are ordinary single- or double-quoted strings (same escape rules as
value strings). Paths are **relative only** and resolve relative to the file
that contains the import directive; `..` segments are allowed. An absolute
path is an error (**E036**).
```mu-err
@@"/etc/base.mu" // error: E036 - absolute import path — import paths are relative only
```
A file may declare that it uses no imports with the `@mu` directive. Any
import directive or spread in such a file is then an error (**E037**).
Parsers running without a file system treat every file as `imports=off`
implicitly.
```mu-err
// @mu imports=off
@@"settings.mu" // error: E037 - imports are disabled for this file (`@mu imports=off`)
```
### Insert in one step
`@@"<path>"` loads the file and inserts its root content at this position.
It always uses that file.
```mu
// settings.mu
env="prod"
port=8080
// main.mu
server(
@@"settings.mu"
retries=534
)
```
After linking, `server(…)` contains `env`, `port`, and `retries` in source
order.
Multiple `@@` lines may load **different** files in one body:
```mu
// lib/breakpoints.mu
breakpoints(sm=640, md=900)
// lib/theme.mu
theme(
dark(palette={ ink="#eee" })
)
// app.mu
@@"lib/breakpoints.mu"
@@"lib/theme.mu"
style(theme="dark")
```
After linking, the root contains items from each dependency in source order.
`@@` does not register a reusable name - there is no `@alias` for that path
afterward.
### Name a file
`@"<path>"` registers an import binding in the current scope. The dependency
file is read when a spread uses that binding, not when the declaration alone
appears.
When the path's basename (directory stripped, `.mu` suffix removed) is a
valid identifier, it becomes the implicit alias:
```mu
@"settings.mu" // implicit alias settings
```
When the stem is not a valid identifier, supply an explicit alias that **abuts**
the closing `"` (no whitespace). Note that a hyphenated stem such as
`my-config` is already a valid identifier, so it needs no alias - only stems
that break the identifier rules do:
```mu
@"2024-report.mu"report
```
```mu-err
@"2024-report.mu" // error: E027 - path stem is not a valid identifier — use an explicit alias, or `@@`
```
When whitespace follows the closing `"`, the next identifier is **not** part of
the import - it is a separate body item (typically boolean shorthand):
```mu
@"settings.mu" settings // import (implicit alias settings) + settings=true
```
The import uses the path stem (`settings`); the spaced `settings` is boolean
shorthand for `settings=true`, not an explicit alias.
A declared import that is never spread anywhere in its scope subtree has no
effect; linters warn (**W031**).
```mu
@"settings.mu" // W031: import is never spread in its scope
```
### Insert by name
After `@"<path>"`, `@<alias>` inserts that file's root content here.
```mu
server(
@"settings.mu"
@settings
retries=534
)
```
Imports and spreads follow the same separator rules as other body items - comma
**or** whitespace on one line, newline alone across lines:
```mu
server(@"settings.mu", retries=534, @settings, debug)
server(@"settings.mu" retries=534 @settings debug)
server(@"settings.mu" settings @settings) // import + shorthand + spread
server(
@"settings.mu"
retries=534
@settings
debug
)
```
### Where names apply
An import binding is visible in the **declaring body** and **nested** bodies.
**Sibling** bodies do not see it. Aliases occupy their own namespace: an
alias `settings`, a param key `settings`, and a child node tagged `settings`
coexist in one scope without clashing.
```mu-err
server(
@"settings.mu"
@settings
again(
@settings // ok - same binding, once per again() body
)
)
debug(
@settings // error: E026 - undeclared spread — sibling, out of scope
)
```
`@@` leaves no binding for descendants:
```mu-err
wrapper(
@@"settings.mu"
inner(
@settings // error: E026 - undeclared spread — `@@` leaves no binding
)
)
```
### Awkward filenames
Use an explicit alias whenever the stem is not a valid identifier, or when
you want a different name:
```mu
@"lib/ui-chrome.mu"chrome
@"sd@!!.mu"settings
```
```mu-err
@@"settings.mu"settings // error: E034 - explicit alias after `@@"path"` is not allowed
```
```mu
@@"settings.mu" settings // ok - spread + boolean shorthand settings=true
```
### Multiple spreads
A body may contain any number of `@@` and `@alias` spreads when each targets
a **distinct** dependency:
- `@@`: resolved paths (POSIX-normalized, relative to the declaring file) must
differ.
- `@alias`: alias identifiers must differ.
```mu
@@"lib/breakpoints.mu"
@@"lib/chrome-shaders.mu"
@@"lib/site-theme.mu"
```
Different child nodes each get their own spread set.
Repeating the **same** spread target in one body is **E033** - regardless of
which form inserts it. An `@alias` spread and an `@@` spread collide when
they resolve to the same file:
```mu-err
server(
@"settings.mu"
@settings
@settings // error: E033 - duplicate spread of the same resolved target — same alias
)
```
```mu-err
server(
@@"settings.mu"
@@"settings.mu" // error: E033 - duplicate spread of the same resolved target — same resolved path
)
```
```mu-err
server(
@"settings.mu"
@settings
@@"settings.mu" // error: E033 - duplicate spread of the same resolved target — via a different form
)
```
Equivalent paths resolve to the same target (`./settings.mu` and `settings.mu`
from the same directory → **E033** on the second `@@`).
### Link merge at spread sites
When a spread inserts a dependency file's root items into the linked view,
items accumulate in **source order** across spreads and local body items:
| Item kind | Merge rule |
|---|---|
| **param** (`key=value`) | **Shadow** - later item replaces an earlier value for the same key |
| **raw value** (positional) | **Append** - all values kept; duplicates allowed |
| **child node** | **Append** - all nodes kept; duplicate tags allowed |
Local items authored after spreads follow the same rules (`retries=534` after
`@@` blocks can shadow an imported `retries` param). See
[Data Model](#data-model) for `params` vs `values[]` / `children[]`.
### Allowed places
Import directives and spreads are **body items only**: they may appear in the
file root or in a node body, never in value position - not after `=`, not as
a list element, not as an object field value (**E038**). To use a file's
content as a value, wrap the spread in an untagged node, where it is a body
item again:
```mu
server_config=(@settings) // ok - untagged node value whose body is one spread
handlers=[(@settings)] // ok - node value inside a list
```
```mu-err
retry=@settings // error: E038 - an import or spread cannot be a value — wrap it in an untagged node: `(@alias)`
tags=[@settings] // error: E038 - an import or spread cannot be a list element — wrap it in an untagged node: `[(@alias)]`
config={chrome=@settings} // error: E038 - an import or spread cannot be a value — wrap it in an untagged node: `(@alias)`
```
The diagnostic for **E038** must suggest the `( … )` wrapping.
### Circular imports
When imports form a cycle, linters warn with **W032** on each import directive
in the cycle. Every message includes the full resolved chain:
```text
W032: circular import: main.mu → lib/a.mu → lib/b.mu → main.mu
```
Implementations detect the **back-edge** (importing an ancestor already on the
parse stack), emit **W032** on every participating import, and **do not**
re-traverse that edge - so there is no endless import loop. Parsing and linking
continue; the linked view skips re-inserting content across the blocked
back-edge. See [`linter.md` section 9.7](linter.md#97-circular-imports-w032).
---
## Data Model
Every scope - the file root or a node body - exposes four views. The
terminology is fixed, and a **node is itself a value**: it can sit in a list,
an object field, or on the right of `=`.
| Term | Meaning |
|---|---|
| **raw value** | keyless body item (`"localhost"`, `8080`, `true`) |
| **param** | keyed value (`key=value`), including boolean shorthand |
| **node** | child node item (`db()`, `debug()`) - not boolean shorthand |
| **import declaration** | `@"<path>"` (implicit alias) or `@"<path>"<alias>` (explicit, abutting) - registers a file; `@"<path>" id` is import + boolean shorthand |
| **import-and-insert** | `@@"<path>"` - loads and inserts in one step; never takes an alias |
| **spread** | `@<alias>` - inserts via a prior import declaration |
Canonical storage (lossless):
| Field | Meaning |
|---|---|
| `content[]` | ordered list of all body items; general index = `content[i]` |
| `values[]` | ordered list of raw values only (no params, no nodes) |
| `params` | keyed values as a key-addressable map (`key → value`) |
| `children[]` | ordered list of child nodes (duplicate tags allowed) |
Import directives and spreads live in `content[]` in source order. After
linking, inserted items from dependency files appear at spread sites in the
linked view; per-file source ASTs keep the original import and spread tokens.
```mu
server(
db()
"localhost"
true
cors(debug=true)
easterEgg=true
1
)
```
```text
content[0] = node, tag db
content[1] = raw "localhost" → values[0]
content[2] = raw true → values[1]
content[3] = node, tag cors
content[4] = param easterEgg=true → params["easterEgg"]
content[5] = raw 1 → values[2]
```
The four kinds are distinct, so names never clash across them: a param is a
key/value pair, a node is not - a key `debug` and a child node tagged `debug`
coexist without ambiguity.
```mu
status(debug=true, debug()) // no clash: params["debug"] and one child tagged debug
```
**Choosing between an object and a node:** use `{}` when the thing is pure
data - named fields only. Use a node when it has identity (a tag), order,
positional values, or children.
There is no mandated nesting-depth limit. Implementations may impose one, but
exceeding it must produce a clean error, never a crash or silent truncation.
---
## Complete Example
This example combines the syntax from the previous sections.
```mu
// @mu version=0.1
/*
Kitchen-sink file: comments, every value type, scopes, separators,
and layout styles from the sections above.
*/
app="demo"
environment="dev"
debug
tagline='Say "hello"'
note="It's fine"
escaped="Say \"hello\""
path="C:\\Users\\alice\\doc.txt"
empty=""
''
cols="a\tb"
utf8="Sao Paulo"
snowman="\u{2603}"
apostrophe='It\'s fine'
+99
0xDEADBEEF
0o755
0b11010110
3.1415
-2E-2
6.626e-34
inf
+inf
-inf
nan
+nan
-nan
verbose=false
missing=null
true
false
null
name="Markup"
pool_size=10
pool-size=8080 // I004: identifier differs from the dominant snake casing
_internal=0
True=1
"Content-Type"="text/plain"
'X-Trace'="abc123"
"true"=1
tags=["web", "api", "v2"]
matrix=[[1, 2], [3, 4]]
settings={host="localhost", port=8080, tls=true}
headers={"Content-Type"="application/json", "X-Custom"="yes"}
()
(42)
"wow"
'''
#!/bin/bash
echo "hello"
'''
prologue="""
Alpha
Beta
"""
// W001: `"""` anchor at column 0 — use raw `'''` mode instead
spec_sample="""mu
motd="""
Hello
"""
"""mu
server(
"localhost" // positional host
8080 // positional port
tls
routes=[
route(path="/", handler=page(title="Home"))
route(path="/docs", handler=page(title="Docs"))
]
config={
retries=3
timeout=+1.5
limit=inf
}
/*
Empty tagged child node.
Formatter preserves this as health_check().
*/
health_check()
banner="""
Welcome to the demo.
Everything in this file is acceptable syntax.
"""
host="127.0.0.1", port=9090, mode="admin"
tags_block=[
"web"
"api"
"v2"
]
cards=[
card(title="Slots")
card(title="Live")
()
]
service={
name="api"
enabled=true
tags=["web", "v2"]
"Accept-Language"="en-US"
endpoint=server(host="localhost", port=8080)
metadata={owner="ops", tier=1}
}
debug()
debug() // duplicate child tags are legal - children form an ordered list
connect("localhost", 8080, true)
job(
"ingest"
retries=3
dry_run
notify(email="ops@example.com")
)
deploy_script='''
set -e
./run.sh
'''
snippet(
lang="sql"
code="""
SELECT *
FROM users
WHERE id = 42
"""
)
pipeline(
"ingest"
debug(verbose=true)
3
)
potato(what={potato="green", salad="blue"}, {fork="blue"})
limits(rate=100, burst=200, window="1m",) // W007: trailing comma
)
limits_inline={rate=100, burst=200}
/*
Lint showcase: everything below is valid syntax, kept intentionally
non-canonical so a linter (and the studio's Lint tab) has warnings and
info to display. TODO the marker in this comment is itself I002.
*/
spaced_out = 1 // I003: whitespace around "="
regrouped=1_23_456 // W008: discouraged numeric grouping
huge=9_007_199_254_740_993 // W009: exceeds safe integer range (±2^53) — doubles lose precision
dense(a=1, b=2, c=3, d=4) // W023: same-line group with 4 or more key=value pairs — consider multiline layout
packed=[1, 2, 3, 4, 5] // W024: same-line list with 4 or more separators — consider multiline layout
first=1, // W026: comma before a newline-separated item — omit commas in multiline layout
second=2
flagged () // W027: boolean shorthand followed by an untagged node — write "flagged()" if a tagged node was intended
tagged="""x
plain text
"""x // W028: multiline delimiter tag is unnecessary for this content
lazy(
) // W011: empty parentheses spread across lines — prefer the same-line form
// I005: a single-param node spread across lines could be inline
lonely(
retries=3
)
```
---
## Appendix A - Grammar
Compact EBNF for the whole language. On conflict with prose or examples, the
grammar is normative. Lexical rules that EBNF cannot express cleanly - anchor
math, dedent, same-line separator rules, and tag-adjacency - carry comments.
```ebnf
file = lead , [ item , { sep , item } , [ "," ] , lead ] , EOF ;
body = lead , [ item , { sep , item } , [ "," ] , lead ] ;
lead = { NEWLINE } ;
sep = "," , lead (* comma follows its item on the same line *)
| whitespace , { whitespace } , lead
(* one or more spaces/tabs on the same line;
zero separation between items is E017 *)
| NEWLINE , lead ; (* a newline alone separates *)
item = param | node | value | import_decl | import_spread | spread_ref ;
(* source order is preserved *)
import_spread = "@@" , ( '"' , { sl-char | escape } , '"'
| "'" , { sl-char | escape } , "'" ) ;
(* no abutting identifier - E034 if present *)
import_decl = "@" , ( '"' , { sl-char | escape } , '"'
| "'" , { sl-char | escape } , "'" )
, [ identifier ] ; (* abutting identifier only = explicit alias;
omitted → implicit alias from path stem when
valid; whitespace after closing quote → next item *)
spread_ref = "@" , identifier ; (* must not begin "@@" or '@"' / "@'" *)
param = key , "=" , value (* key, "=", and the value's FIRST token share a line;
no block comment may touch the "=" on either side *)
| identifier ; (* boolean shorthand: identifier ≡ identifier=true *)
node = [ tag ] , "(" , body , ")" ; (* a tag abuts "(" - zero whitespace *)
value = string | number | boolean | "null" | list | object | node ;
list = "[" , lead , [ value , { sep , value } , [ "," ] , lead ] , "]" ;
object = "{" , lead , [ field , { sep , field } , [ "," ] , lead ] , "}" ;
field = key , "=" , value ; (* duplicate keys in one scope or object: error *)
boolean = "true" | "false" ;
key = identifier | sl-string ; (* sl-string key: non-empty after decoding;
decoded text must be unique per scope
regardless of quoting *)
tag = identifier ;
identifier = ident-start , [ { ident-mid } , ident-end ] ;
(* ASCII only; must not equal a reserved word *)
ident-start = letter | "_" ;
ident-mid = letter | digit | "_" | "-" ;
ident-end = letter | digit | "_" ;
reserved = "true" | "false" | "null" | "inf" | "nan" ;
string = sl-string | ml-string ;
sl-string = '"' , { sl-char | escape } , '"'
| "'" , { sl-char | escape } , "'" ;
(* sl-char: any char except the delimiter, backslash, newline *)
escape = "\" , ( '"' | "'" | "\" | "n" | "t" | "r" )
| "\u{" , hex , [hex] , [hex] , [hex] , [hex] , [hex] , "}" ;
(* must be a Unicode scalar value *)
ml-string = ml-open , NEWLINE , { content-line , NEWLINE } , indent , ml-close ;
ml-open = delim , [ opening-tail ] ; (* nothing else on the line *)
opening-tail = { whitespace } , [ same-line-block-comment , { whitespace } ] , [ line-comment ] ;
same-line-block-comment = "/*" , { any-char-except-NEWLINE } , "*/" ;
ml-close = delim , [ "," ] , [ line-comment ] ; (* nothing else on the line *)
delim = ( '"""' | "'''" ) , [ identifier ] ;
(* exactly three quotes; closer matches the opener char-for-char;
a quote run of four or more is an error.
For """ the anchor = min(opener offset, closer offset), where
offset = characters left of the first quote on that line;
each non-blank content line drops anchor leading whitespace
chars and starting left of the anchor is an error; blank
lines are exempt and contribute one empty line. ''' is
verbatim. Neither form processes escapes. *)
number = [ "+" | "-" ] , ( "inf" | "nan" | dec-int | float )
| radix-int ; (* radix literals are unsigned - TOML rule *)
radix-int = "0x" , hex-digit , { [ "_" ] , hex-digit }
| "0o" , oct-digit , { [ "_" ] , oct-digit }
| "0b" , bin-digit , { [ "_" ] , bin-digit } ;
dec-int = "0" | nonzero , { [ "_" ] , digit } ; (* no leading zeros *)
float = dec-int , frac , [ exp ]
| dec-int , exp ;
frac = "." , digit , { [ "_" ] , digit } ;
exp = ( "e" | "E" ) , [ "+" | "-" ] , digit , { [ "_" ] , digit } ;
comment = line-comment | block-comment ;
line-comment = "//" , { any-char-except-NEWLINE } ;
block-comment = "/*" , { any-char } , "*/" ; (* does not nest *)
```