# 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
