lib/bmx.bx

BMX 0.1, parsed into a typed tree.

use "lib/bmx.bx";

Read the BMX documentation at bmx.burxt-lang.org — BMX is a format with its own specification, guide and conformance suite.

BMX is markdown with one unambiguous reading and a typed hole in it. The format is specified in its own repository, independently of this file and of this language: structure, error codes and a conformance suite as data. This is one implementation of it.

—- The one property that separates BMX from markdown ———————————–

It always fails loudly. *bold with no closing star is an error here, not the characters *bold. An unterminated fence is an error, not a document that silently became one code block. {{ x is an error, not text. Markdown is designed so that nothing is ever a syntax error, which is the opposite of what a language whose compiler refuses things can build on.

Every refusal carries a codeBMX-E001 and its siblings — because a code is what a conformance suite can assert across implementations, while a message is this file’s own words. The codes are the format’s; the wording after them is ours.

—- Where this implementation stands —————————————————-

The format defines two conformance levels. Level 1 is rendering: parse, then substitute slot values with escaping applied. Level 2 is checking: every slot expression verified against a declared interface before the document renders, so a slot naming a field that does not exist is a build error rather than a blank on a page.

This file carries both, and the split is in the file. bmx_to_html is level 1: it refuses a slot with no binding and a link target with a dangerous scheme, but it looks values up by their expression TEXT and nothing there checks a type.

bmx_emit_burxt is level 2, and it is a code GENERATOR rather than a renderer — which is not a limitation but the design. A .bmx document becomes a pure function ... -> Html whose slots are ordinary Burxt expressions, and then the COMPILER does the checking. Measured, all four: a slot naming a field that does not exist is a type error at the expression; a slot holding a Decimal rather than a String is a type error, so the conversion is written in the document where a reviewer sees it; money that would narrow without a rounding contract is refused inside the view; and burxt review diffs the generated signature between versions. tests/runner.rs’s the_bmx_generator_hands_the_compiler_a_view_it_can_check runs every one.

None of that is implemented here. It is what the language already does to any function, which is BOUNDARY.md paying off: the format stays dumb enough for anyone to implement, and the checking lives where it can be enforced.

One consequence of the same boundary, worth knowing before it is met: a BMX document in a Burxt string literal has to escape every brace"\{\{ user.name \}\}" — because { opens a {expr} interpolation and a bare } closes one. It compiles and it is correct; it is merely unreadable, and tests/pass/bmx_library.bx is written that way on purpose to show what it costs.

(This paragraph first said a document “does not go in a string literal” at all. That was over-strong, and the fixture two directories away already disproved it. Corrected 2026-08-16 — the weaker claim is the true one, and the habit of reaching for the stronger one is what put two wrong rows in FAR-HORIZON-ROADMAP.md.)

So BMX lives in .bmx files: read at runtime here, read at build time by the generator. Not because a literal is refused, but because a template inside one is a template no editor highlights, no formatter touches and no conformance suite can reach. The language decision behind this is DESIGN.md’s “The delimiters do not move for an embedded format” (2026-08-16) — the delimiters stay, and the format is the one with a choice left.

—- What it is built on —————————————————————–

lib/html.bx for output, so escaping has exactly one place to be right in this language and BMX does not get a second one. lib/json.bx for the AST, which is how the format’s conformance suite compares implementations.

What is in it

| Name | Kind | What it answers | |—|—|—| | BmxSlot | class | A slot carries its expression AND where it started, because a host must be able to point at the source the author wrote | | BmxWrap | class | — | | BmxLink | class | — | | Bmx | enum | Inline content. | | BmxItem | class | — | | BmxHeading | class | — | | BmxList | class | — | | BmxCode | class | — | | BmxWords | class | — | | Block | enum | Block content. Paragraph and Quote carry the same shape and are still two variants: they mean different things and r | | BmxLine | class | — | | Binding | class | — | | bmx_error | function | BMX-E001 at 6: unterminated slot. The code leads so a conformance harness can compare on a prefix without parsing our | | bmx_is_space | function | — | | bmx_strip_end | function | A line’s content with trailing spaces removed. The format strips them because the two-space line break is an invisible c | | bmx_is_blank | function | — | | bmx_starts_with | function | — | | bmx_ordered_marker | function | The digits at the start of a line, or -1 if there are none. 12. is an ordered marker. | | bmx_parse_inline | function | base is where text starts in the whole document, so a slot’s offset is a real position in the file the author opened | | bmx_merge_text | function | Adjacent Text nodes are always merged. The format requires it — two implementations that disagree about whether a b | | [bmx_lines](#bmx-lines) | function | Split into lines, keeping each line's byte offset. \r\n ends a line and a lone \r does not — a stray carriage return | | [bmx_parse](#bmx-parse) | function | A document, or the first error in it. A conforming parser stops at the first error: recovery is a real want, but recover | | [bmx_inline_json](#bmx-inline-json) | function | — | | [bmx_json](#bmx-json) | function | — | | [bmx_bind](#bmx-bind) | function | — | | [bmx_lookup](#bmx-lookup) | function | — | | [bmx_target_allowed](#bmx-target-allowed) | function | A target with no scheme is relative and allowed. A target with a scheme is allowed only from a named set. The check is " | | [bmx_inline_html](#bmx-inline-html) | function | — | | [bmx_heading_tag](#bmx-heading-tag) | function | — | | [bmx_html](#bmx-html) | function | — | | [bmx_to_html](#bmx-to-html) | function | Source in, page out. The whole path, for the caller who does not need the tree. | | [bmx_burxt_string](#bmx-burxt-string) | function | A Burxt string literal, escaped. { and } are the two a reader will not expect: a bare brace opens or closes an int | | [bmx_emit_inline](#bmx-emit-inline) | function | — | | [bmx_emit_blocks](#bmx-emit-blocks) | function | — | | [bmx_emit_burxt`](#bmx-emit-burxt) | function | A document and a signature in, a Burxt source file out. |

Types

BmxSlot

class BmxSlot { expression: String, offset: Int }

A slot carries its expression AND where it started, because a host must be able to point at the source the author wrote rather than at whatever it generated. The format makes the offset mandatory for that reason.

Source

BmxWrap

class BmxWrap { children: [Bmx] }

Source

class BmxLink { target: String, children: [Bmx] }

Source

Bmx

enum Bmx

Inline content.

Source

BmxItem

class BmxItem { children: [Bmx] }

Source

BmxHeading

class BmxHeading { level: Int, children: [Bmx] }

Source

BmxList

class BmxList { ordered: Bool, items: [BmxItem] }

Source

BmxCode

class BmxCode { info: String, value: String }

Source

BmxWords

class BmxWords { children: [Bmx] }

Source

Block

enum Block

Block content. Paragraph and Quote carry the same shape and are still two variants: they mean different things and render differently, and a kind field would put the distinction somewhere a match cannot see it.

Source

BmxLine

class BmxLine { text: String, offset: Int }

Source

Binding

class Binding { name: String, value: String }

Source

Functions

bmx_error

pure function bmx_error(code: String, offset: Int, message: String) -> String

BMX-E001 at 6: unterminated slot. The code leads so a conformance harness can compare on a prefix without parsing our prose, which is the half of an error the format owns.

Source

bmx_is_space

pure function bmx_is_space(b: Int) -> Bool

Source

bmx_strip_end

pure function bmx_strip_end(text: String) -> String

A line’s content with trailing spaces removed. The format strips them because the two-space line break is an invisible character that changes output, which is unreviewable by construction.

Source

bmx_is_blank

pure function bmx_is_blank(text: String) -> Bool

Source

bmx_starts_with

pure function bmx_starts_with(text: String, prefix: String) -> Bool

Source

bmx_ordered_marker

pure function bmx_ordered_marker(text: String) -> Int

The digits at the start of a line, or -1 if there are none. 12. is an ordered marker.

Source

bmx_parse_inline

function bmx_parse_inline(text: String, base: Int) -> Result<[Bmx], String>

base is where text starts in the whole document, so a slot’s offset is a real position in the file the author opened rather than an index into a fragment.

Source

bmx_merge_text

function bmx_merge_text(nodes: [Bmx]) -> [Bmx]

Adjacent Text nodes are always merged. The format requires it — two implementations that disagree about whether a b is one node or two disagree about the document, and the conformance suite would rightly fail one of them.

This exists because inline content is parsed one line at a time rather than over a joined buffer. That is what keeps a slot’s offset pointing at the author’s source: joining first and parsing after put the offset off by exactly the trailing spaces stripped from every earlier line, measured at 11 where the byte was at 14. It is also what the spec already implied — every inline construct must close on its own line, so there was never a reason to parse across one.

Source

bmx_lines

function bmx_lines(source: String) -> [BmxLine]

Split into lines, keeping each line’s byte offset. \r\n ends a line and a lone \r does not — a stray carriage return in the middle of a line is far more likely to be data than intent, and the format says so rather than leaving it to each parser.

Source

bmx_parse

function bmx_parse(source: String) -> Result<[Block], String>

A document, or the first error in it. A conforming parser stops at the first error: recovery is a real want, but recovery that differs between implementations is worse than none.

Source

bmx_inline_json

function bmx_inline_json(nodes: [Bmx]) -> Json

Source

bmx_json

function bmx_json(blocks: [Block]) -> Json

Source

bmx_bind

pure function bmx_bind(name: String, value: String) -> Binding

Source

bmx_lookup

pure function bmx_lookup(bindings: [Binding], name: String) -> Option<String>

Source

bmx_target_allowed

pure function bmx_target_allowed(target: String) -> Bool

A target with no scheme is relative and allowed. A target with a scheme is allowed only from a named set. The check is “is there a : before the first /”, which is what distinguishes mailto:a@b and javascript:x from /page and a/b:c.

Source

bmx_inline_html

function bmx_inline_html(nodes: [Bmx], bindings: [Binding]) -> Result<[Html], String>

Source

bmx_heading_tag

pure function bmx_heading_tag(level: Int) -> String

Source

bmx_html

function bmx_html(blocks: [Block], bindings: [Binding]) -> Result<Html, String>

Source

bmx_to_html

function bmx_to_html(source: String, bindings: [Binding]) -> Result<String, String>

Source in, page out. The whole path, for the caller who does not need the tree.

Source

bmx_burxt_string

pure function bmx_burxt_string(text: String) -> String

A Burxt string literal, escaped. \{ and \} are the two a reader will not expect: a bare brace opens or closes an interpolation, so a document’s own braces have to survive the trip into a literal.

Source

bmx_emit_inline

function bmx_emit_inline(nodes: [Bmx]) -> Result<String, String>

Source

bmx_emit_blocks

function bmx_emit_blocks(blocks: [Block]) -> Result<String, String>

Source

bmx_emit_burxt

function bmx_emit_burxt(blocks: [Block], source_name: String, name: String, parameters: String, clauses: [String]) -> Result<String, String>

A document and a signature in, a Burxt source file out.

parameters is written verbatim into the signature ("order: Order") and requires is one clause per entry. Both come from the caller because the FORMAT does not carry them — see the note above about front matter.

Source