# Library guide — `@mu-lang/parser`
Complete guide to the TypeScript toolkit for Markup (`.mu`): install, parse,
format, lint, typed schemas, multi-file imports, and the CLI.
This page is for **application authors** who consume Markup as data. For the
language itself, see the [Specification](/) tab.
> **⚠ Experimental (v0.x).** The Markup language and this library are still
> evolving. Expect breaking changes to the API, the value model, and the `.mu`
> syntax as the project matures toward 1.0. Pin an exact version
> (`@mu-lang/parser@0.1.0`) if you depend on it in production.
---
## What you get
One npm package covers the whole toolchain:
| Capability | Entry points |
| --- | --- |
| Parse → document | `parse`, `parseSafe`, `parseSource` |
| Typed structure | `mu`, `parseAs`, `loadAs`, `Infer` |
| Format | `format` |
| Lint | `lint`, `lintProjectFile` |
| Multi-file imports | `load`, `openProject`, `linked()` |
| Serialize | `stringify`, `stringifyNode` |
| CLI | `mu fmt`, `mu lint`, `mu parse` |
Package: **`@mu-lang/parser`** (Node 18+, ESM + CJS). License: MIT OR Apache-2.0.
---
## Install
```bash
npm install @mu-lang/parser
```
Or with pnpm / yarn / bun:
```bash
pnpm add @mu-lang/parser
yarn add @mu-lang/parser
bun add @mu-lang/parser
```
After install, the `mu` binary is available via `npx mu` or your package
manager’s bin path.
### TypeScript
The package ships its own types. No extra `@types` package is required.
```json
// tsconfig.json — "moduleResolution": "bundler" or "node16" works
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true
}
}
```
### Browser vs Node
| Environment | Notes |
| --- | --- |
| Node | Full surface: `load` / `loadAs` with `fs`, CLI |
| Browser / bundler | `parse`, `parseAs`, `format`, `lint`, `stringify` — inject your own `readFile` for multi-file |
| Edge / Workers | Same as browser; avoid sync FS APIs |
---
## Quick start
```typescript
import {
parse,
format,
lint,
isMuNode,
isMuParamEntry,
} from '@mu-lang/parser';
const src = `
title="Ops Console"
server(host="localhost", port=8080, tls)
`;
const doc = parse(src);
// Channel views
console.log(doc.params.title); // "Ops Console"
console.log(doc.nodes[0]?.tag); // "server"
console.log(doc.nodes[0]?.params.port); // 8080
// Full body order (same as doc.items) — params + positionals + children
console.log(doc.length); // 2
console.log(isMuParamEntry(doc[0])); // true → { kind: 'param', key: 'title', value: 'Ops Console' }
console.log(isMuNode(doc[1])); // true → the server(...) node
console.log(doc[1]?.params?.port); // 8080 (if you narrow as MuNode)
// Walk any node the same way
const server = doc.nodes[0]!;
console.log(server[0]); // { kind: 'param', key: 'host', value: 'localhost' }
console.log(server[1]); // { kind: 'param', key: 'port', value: 8080 }
console.log(server[2]); // { kind: 'param', key: 'tls', value: true }
console.log(format('a=1,b=2')); // "a=1, b=2\n"
console.log(lint(src)); // [] when clean
```
That’s enough for many apps: **parse**, read **params** / **nodes** or walk
**`doc[i]`**, optionally **format** or **lint**. See [Walking body order](#walking-body-order-nodei) for the full rules.
---
## Mental model
A Markup **scope** (the file root or any node body) is not a single JSON object.
It has **three channel views** plus **full-order walk**:
| Access | What |
| --- | --- |
| `node.params` | Dict of named params (`key=value`, bare `tls` → `true`) — plain JS |
| `node.values` | List of **keyless positionals only** — **raw** plain JS (`"hi"`, `42`, …) |
| `node.nodes` | List of **child nodes only** — `MuNode` objects |
| `node[i]` / `node.items` | **Everything in source order** (params + positionals + children) |
```text
params → { host: "x", tls: true } // by name
values → [true, "world"] // positionals only, raw
nodes → [optsNode] // children only
node[i] → walk body in the order written
```
The **file root** is an untagged node (`tag === ''`). Top-level lines *are* that
body — there is no outer `root(…)`.
```mu
title="Ops" // root param
42 // root positional
server(port=8080) // root child node
```
### Value encoding
- **`params`** and **`values`** are **plain JS** (already unwrapped). Nested
Markup nodes still appear as `MuNode`.
- Proxy sugar: non-reserved param names also appear as `node.port` (same as
`node.params.port`). Reserved: `tag`, `params`, `nodes`, `values`, `items`,
`kind`, `isEmpty`, `toJSON`, `length`.
### Nodes vs objects
| Form | Use when |
| --- | --- |
| `{ host="x", port=1 }` | Pure named data (maps cleanly to JSON) |
| `server(host="x", port=1)` | Identity (tag), children, positionals, or dialect “elements” |
### Walking body order (`node[i]`)
There is **no** `node[0]` syntax inside `.mu` source. After `parse`, every body
item has an index in **source order**:
| Body item | `node[i]` returns |
| --- | --- |
| Param (`host="x"`, bare `a`) | `{ kind: 'param', key, value }` — `value` is plain JS |
| Positional (`true`, `"hi"`, `42`) | **Raw** plain JS (same as that slot in `values`) |
| Child node (`server(…)`) | **`MuNode`** (same object family as `nodes`) |
Helpers: `isMuParamEntry`, `isMuNode`, `atParam(node, i)`, `atNode(node, i)`.
```typescript
import { parse, isMuNode, isMuParamEntry, atParam, atNode } from '@mu-lang/parser';
const n = parse(`
test(
a
"test"
server(port=1)
host="x"
)
`).nodes[0]!;
n.params; // { a: true, host: "x" }
n.values; // ["test"]
n.nodes[0]?.tag; // "server"
n[0]; // { kind: 'param', key: 'a', value: true }
n[1]; // "test"
n[2]; // MuNode tag "server"
n[3]; // { kind: 'param', key: 'host', value: 'x' }
n.length; // 4
isMuParamEntry(n[0]); // true
atParam(n, 0)?.key; // "a"
isMuNode(n[2]); // true
atNode(n, 2)?.tag; // "server"
// Function-call style: positionals + child, then named kwargs
const call = parse(`greet(true, "world", opts(loud))`).nodes[0]!;
call[0]; // true
call[1]; // "world"
call[2]; // MuNode "opts"
call.values; // [true, "world"]
```
---
## Parsing
### `parse` — throw on errors
```typescript
import { parse, MarkupParseError } from '@mu-lang/parser';
try {
const doc = parse('server(host="x", port=8080)');
// Document = MuNode (root)
} catch (e) {
if (e instanceof MarkupParseError) {
console.error(e.message, e.diagnostics);
}
}
```
### `parseSafe` — never throws
```typescript
import { parseSafe } from '@mu-lang/parser';
const { ok, document, diagnostics } = parseSafe(source);
if (!ok || !document) {
// show diagnostics
return;
}
// use document
```
### `parseSource` — document + AST
When you need spans or import metadata:
```typescript
import { parseSource } from '@mu-lang/parser';
const { document, ast, diagnostics } = parseSource(source);
// ast is the span-rich Root used by tooling
```
### Navigating a document
```typescript
import {
parse,
childrenByTag,
childByTag,
getParam,
isMuNode,
isMuParamEntry,
} from '@mu-lang/parser';
const doc = parse(`
title="App"
server(host="a", port=1)
server(host="b", port=2)
database(engine="postgres")
`);
getParam(doc, 'title'); // "App"
childrenByTag(doc, 'server'); // two nodes
childByTag(doc, 'database'); // one node (throws if multiple by default)
// Full body order (params + positionals + children) — same as doc.items / doc[i]
for (const item of doc) {
if (isMuParamEntry(item)) console.log('param', item.key, item.value);
else if (isMuNode(item)) console.log('node', item.tag);
else console.log('value', item); // raw positional
}
```
### JSON projection
```typescript
const doc = parse('a=1\nb="x"');
console.log(JSON.stringify(doc, null, 2)); // uses toJSON()
// CLI: mu parse file.mu
```
---
## Format and lint
### Format
```typescript
import { format, MarkupError } from '@mu-lang/parser';
// Throws MarkupError if the input has E-level syntax errors
const out = format(source, {
width: 80,
indent: 2,
// pack, blanks, … also via // @mu directives in the file
});
```
File directives (comments) configure tools without code:
```mu
// @mu version=0.1, width=100, indent=2, suppress=W007
```
### Lint
```typescript
import { lint } from '@mu-lang/parser';
const diags = lint(source);
// { code, severity, message, line, col, endLine?, endCol? }[]
for (const d of diags) {
console.log(`${d.severity} ${d.code}: ${d.message} @${d.line}:${d.col}`);
}
```
Project-aware lint (imports):
```typescript
import { lintProjectFile } from '@mu-lang/parser';
import { readFileSync } from 'node:fs';
const diags = await lintProjectFile('app.mu', (p) => readFileSync(p, 'utf8'));
```
---
## Typed structure with schemas
When you accept a **specific shape** (config, UI dialect, scene file), declare it
in TypeScript. Runtime validation + `Infer<>` types.
### Minimal config
```typescript
import { mu, parseAs, type Infer } from '@mu-lang/parser';
const Config = mu.document({
params: {
title: mu.string(),
enabled: mu.boolean().default(false),
},
children: {
server: mu
.node('server', {
params: {
host: mu.string(),
port: mu.int(),
tls: mu.boolean().default(false),
},
})
.one(),
},
values: mu.none(), // reject stray root positionals
});
type Config = Infer<typeof Config>;
const config = parseAs(
`
title="Ops"
server(host="localhost", port=8080, tls)
`,
Config,
);
config.title; // string
config.server.port; // number
config.enabled; // boolean (default applied)
```
### Ordered body (`items` / `node[i]`)
When a node is used like a **function call**, declare the full source-order
body with `items`. Each slot is validated against that index of `node.items`:
| Slot schema | Matches |
| --- | --- |
| `mu.string()` / `mu.int()` / … | Raw positional |
| `mu.node('tag', { … })` | Child node with that tag |
| `mu.param('key', schema)` | Param entry `{ key, value }` at that index |
| `mu.param(schema)` | Any param key; value must match |
```typescript
const Opts = mu.node('opts', {
params: { loud: mu.boolean().default(false) },
});
const Greet = mu.node('greet', {
items: [mu.boolean(), mu.string(), Opts] as const,
});
// greet(true, "world", opts(loud))
// → { $items: [true, "world", { loud: true }] }
```
Mixed param + positional + child (same as Document `node[i]`):
```typescript
const Test = mu.node('test', {
items: [
mu.param('a', mu.boolean()),
mu.string(),
mu.node('server', { params: { port: mu.int() } }),
mu.param('host', mu.string()),
] as const,
});
// test(a, "test", server(port=1), host="x")
// → $items: [{key:'a', value:true}, "test", {port:1}, {key:'host', value:'x'}]
```
You can still declare `params` / `children` / `values` **alongside** `items`
(named lookup + ordered walk). With `items` only, channel strictness defaults
off so undeclared params/children do not fail unless you set `strict*`.
Projection field: **`$items`** (tuple-typed via `Infer`). Tree mode
(`.asTree()`) also exposes `items` on the structured result.
### Scalar and container builders
| Builder | Accepts |
| --- | --- |
| `mu.string()` | string (`.minLength` / `.maxLength`) |
| `mu.number()` | number; Markup `inf`/`nan` allowed (`.finite()` to reject) |
| `mu.int()` | integer |
| `mu.float()` | number |
| `mu.boolean()` | boolean |
| `mu.null()` | null |
| `mu.literal(x)` | exact value |
| `mu.enums(['a','b'] as const)` | string union |
| `mu.param(key, schema)` | Body-order param slot (for `items`) |
| `mu.any()` | anything |
| `mu.object({ … })` | plain object / Markup `{}` (strict keys by default; `.passthrough()`) |
| `mu.list(item)` | array |
| `mu.tuple([a, b])` | fixed-length list |
| `mu.union([a, b])` | first match |
| `mu.record(value)` | open string keys |
| `mu.none()` | empty positionals list |
Value schemas chain **`.optional()`** and **`.default(v)`**.
### Node and document
```typescript
const Route = mu.node('route', {
params: { path: mu.string(), method: mu.enums(['GET', 'POST'] as const).default('GET') },
});
const App = mu.document({
params: { name: mu.string() },
children: {
route: Route.many(), // zero or more
// also: .one() | .optional()
},
values: mu.none(),
// strictParams / strictChildren / strictValues default true
});
```
**Child policies** (on a node schema):
- `.one()` — exactly one child with that tag
- `.many()` — zero or more → array
- `.optional()` — zero or one
Bare `mu.node('x', …)` as a child entry means `.one()`.
**Projection:** params and children flatten into one object (`config.server.host`).
Positionals validated with a non-`none` values schema appear as **`$values`**.
**Tree mode** (UI-style):
```typescript
const Card = mu
.node('card', {
params: { title: mu.string().optional() },
strictValues: false,
})
.asTree();
// → { tag, params, children, values }
```
### Errors
```typescript
import { parseAs, parseAsSafe, MarkupParseError, MarkupSchemaError } from '@mu-lang/parser';
try {
parseAs(source, Config);
} catch (e) {
if (e instanceof MarkupParseError) {
// language / syntax (E###)
} else if (e instanceof MarkupSchemaError) {
for (const issue of e.issues) {
console.log(issue.code, issue.message, issue.path);
}
}
}
const r = parseAsSafe(source, Config);
if (!r.ok) {
// r.diagnostics — language
// r.issues — schema
}
```
### Imports and `parseAs`
`parseAs` is **single-file only**. If the source uses `@` / `@@` imports, it
throws (or safe-fails) and tells you to use **`loadAs`**. Nested imports inside
nodes are detected too.
---
## Loading files and multi-file projects
### Simple load (linked document)
```typescript
import { readFileSync } from 'node:fs';
import { loadSync, loadAsSync } from '@mu-lang/parser';
const readFile = (path: string) => readFileSync(path, 'utf8');
const doc = loadSync('config.mu', { readFile });
// imports expanded; doc is the linked MuNode
const config = loadAsSync('config.mu', Config, { readFile });
// linked + schema-projected
```
Async variants: `load`, `loadAs`, `loadAsSafe` (and `loadAsSafeSync`).
Always inject **`readFile`** — the library never hard-codes `fs`, so the same
API works in tests with a `Map` of paths.
### Lower-level project API
```typescript
import { openProjectAsync, waitForReady } from '@mu-lang/parser';
const project = await openProjectAsync({
entryPath: 'app.mu',
readFile: async (p) => /* … */,
});
await waitForReady(project);
project.linked(); // Document | null
project.allDiagnostics();
project.files; // per-file parse results
```
Sync: `openProjectSync` when `readFile` is synchronous.
### Import merge rules (reminder)
| Channel | On spread |
| --- | --- |
| params | later shadows earlier |
| positionals | append |
| children | append (duplicate tags fine) |
Schema validation should run on the **linked** document.
---
## Serializing data back to Markup
```typescript
import { stringify, stringifyNode, parseAs } from '@mu-lang/parser';
const data = {
title: 'Ops',
settings: { host: 'local', tls: false },
server: { $tag: 'server', host: 'localhost', port: 8080 },
};
const source = stringify(data);
// title="Ops"
// settings={host="local", tls=false}
// server(host="localhost", port=8080)
// Multi-line nodes:
stringify(data, { indent: 2 });
// Explicit node helper:
stringifyNode('card', {
values: ['Hello'],
params: { theme: 'dark' },
children: [{ $tag: 'label', text: 'x' }],
});
```
**Not** a lossless round-trip of comments or exact layout — good for codegen,
tests, and config emission. Round-trip through `parseAs` for schema-shaped data.
---
## CLI
```bash
npx mu fmt file.mu # print formatted source
npx mu lint file.mu # diagnostics; exit 1 on errors
npx mu parse file.mu # JSON projection of the document
```
`mu fmt` options: `--width <n>`, `--indent <n>`, `--pack`, `--collapse-blanks`.
Omit the file (or pass `-`) to read stdin for `fmt` / `parse`.
---
## Recipes
### 1. Config file in a Node app
```typescript
import { readFileSync } from 'node:fs';
import { mu, loadAsSync } from '@mu-lang/parser';
const Schema = mu.document({
params: {
databaseUrl: mu.string(),
port: mu.int().default(3000),
},
values: mu.none(),
});
export function loadConfig(path = 'app.mu') {
return loadAsSync(path, Schema, {
readFile: (p) => readFileSync(p, 'utf8'),
});
}
```
### 2. Editor diagnostics (LSP-style)
```typescript
import { lint, spanToLspRange, analyze } from '@mu-lang/parser';
// Simple: line/col list
lint(source);
// Spans for precise ranges:
const diags = analyze(source);
for (const d of diags) {
const range = spanToLspRange(d.span);
// map to your editor API
}
```
### 3. Format-on-save pipeline
```typescript
import { format, MarkupError } from '@mu-lang/parser';
export function formatOnSave(source: string): string {
try {
return format(source);
} catch (e) {
if (e instanceof MarkupError) return source; // refuse broken input
throw e;
}
}
```
### 4. Node-as-param (handlers, plugins)
```typescript
const Plugin = mu.node('plugin', {
params: { name: mu.string(), enabled: mu.boolean().default(true) },
});
const App = mu.document({
params: {
handler: Plugin, // param value is a node
},
values: mu.none(),
});
parseAs('handler=plugin(name="auth")', App);
// → { handler: { name: 'auth', enabled: true } }
```
### 5. Browser: parse without FS
```typescript
import { parseAs, mu } from '@mu-lang/parser';
const Schema = mu.document({
params: { title: mu.string() },
values: mu.none(),
});
export function fromTextarea(value: string) {
return parseAs(value, Schema);
}
```
### 6. Unit tests with virtual files
```typescript
import { loadAsSync, mu } from '@mu-lang/parser';
const files = new Map([
['/app.mu', 'title="T"\n@@"./settings.mu"\n'],
['/settings.mu', 'env="test"\n'],
]);
const Schema = mu.document({
params: { title: mu.string(), env: mu.string() },
values: mu.none(),
});
const cfg = loadAsSync('/app.mu', Schema, {
readFile: (p) => {
const s = files.get(p);
if (s === undefined) throw new Error(p);
return s;
},
});
```
---
## API map (cheat sheet)
| Export | Role |
| --- | --- |
| `parse` / `parseSafe` / `parseSource` / `parseBytes` | Document parse |
| `parseAst` | Span-rich AST (tooling) |
| `mu` / `parseAs` / `parseAsSafe` / `projectWithSchema` | Schema dialect |
| `Infer` / `MarkupSchemaError` | Types and schema errors |
| `load` / `loadAs` / `loadAsSafe` (+ Sync) | Files + imports |
| `openProject` / `waitForReady` / `linked` | Project graph |
| `format` / `lint` / `analyze` | Style tools |
| `stringify` / `stringifyNode` | Emit `.mu` |
| `childrenByTag` / `getParam` / `atNode` / `atParam` | Navigation |
| `isMuNode` / `isMuParamEntry` | Discriminate body items |
| `node.params` / `node.values` / `node.nodes` | Channel views (dict / raw list / nodes) |
| `node[i]` / `node.items` / `node.length` | Full source-order walk (params + values + nodes) |
| `lex` / `DiagCode` / `Severity` | Low-level |
---
## Limits and design notes
1. **Schema is a host dialect**, not Markup syntax — different apps define different catalogs.
2. **No Zod dependency** — optional adapters can wrap projected plain objects later.
3. **Integer vs float** in the AST can collapse to JS `number` after unwrap; use care for `42` vs `42.0` if it matters.
4. **`parseAs` vs `loadAs`** — imports always need linking first.
5. **Strict by default** — unknown params/children fail unless you set `strictParams: false` / `strictChildren: false`.
---
## Next steps
- Language rules: [Specification](/) (`spec/syntax.md`)
- Try examples: [Playground](/playground)
- See live dialects: [Demos](/demos)
- Package README: `packages/typescript/README.md` in the monorepo