# markdown-contract
Say what a markdown document of a given type must contain, and check it, for Rust.
Markdown files are used as data: notes, planning docs, decision records,
runbooks. People edit them by hand, so they drift from the shape the tools
that read them expect. A required section goes missing, a table loses a
column, a frontmatter field has the wrong type.
A **contract** states what one type of document must hold: a schema for the
frontmatter, a grammar over its heading sections, and typed blocks (tables,
lists, code, paragraphs) inside them. One parse checks the document and
returns findings, each with a finding id, a level and a line number.
- `load_contract` compiles a YAML contract (`mcVersion: 2`) and `validate`
checks a document against it. `validate` does not fail on a bad document; it
returns the findings, sorted by line.
- A contract can also be built in code with `Contract` and the builders
`section`, `sections`, `optional`, `one_of`, `gap` and `rule`.
- `load_config` and `run_corpus` route the files of a folder to contracts by
glob and return the findings and an exit code (0 when no finding has level
`error`, 1 otherwise).
- `parse_document` gives the document tree the checks read. Every section
and block carries its byte range in the body.
- `select` picks part of a note the way an Obsidian link fragment does:
`#Heading`, `#Parent#Child` and `#^block-id`.
- The crate never changes the documents it checks.
This is the Rust twin of the npm package
[`@osdlc/markdown-contract`](https://www.npmjs.com/package/@osdlc/markdown-contract).
It reads the same YAML contracts. A shared corpus checks that both give the
same findings; see [Parity with the TypeScript package](#parity-with-the-typescript-package)
for exactly what it checks.
## Install
```sh
cargo add markdown-contract
```
### Cargo features
- `native` (on by default): the code that reads files. `load_contract_file`,
`load_config_file`, `walk_dir` and `run_corpus_dir`. Without it the crate
does no I/O: every function takes strings. Build with
`default-features = false` for `wasm32-unknown-unknown`:
```toml
markdown-contract = { version = "0.4", default-features = false }
```
## Quick start
```rust
use markdown_contract::{FindingLevel, load_contract, validate};
let contract = load_contract("\
mcVersion: 2
kind: contract
frontmatter:
type: object
required: [status]
properties:
status: { enum: [open, accepted] }
body:
order: strict
additionalSections: false
sections:
- section: Context
- section: Decision
")?;
let document = "---\nstatus: done\n---\n\n## Context\n\nConfig is checked by hand.\n";
let findings = validate(document, &contract, "decisions/zod.md");
// `status: done` is not in the enum, and the Decision section is missing.
assert_eq!(findings.len(), 2);
assert_eq!(findings[0].id, "frontmatter/enum");
assert_eq!(findings[0].pos.map(|p| p.line), Some(2));
assert_eq!(findings[1].id, "structure/section-missing");
```
`examples/validate.rs` is the same program with printed output. Run it with
`cargo run --example validate`.
## Selecting part of a note
`select` reads a link fragment as Obsidian does. Every ATX heading counts,
an H1 included; a section runs to the next heading of the same or a
shallower level; a heading inside fenced code is not a heading; and a path
(`Parent#Child`) needs its leading segments to be the nearest headings above
the last one. Each function returns the selection trimmed, as text or as a
byte range in the body.
```rust
use markdown_contract::{parse_document, select};
let note = "---\ntitle: x\n---\n# Guide\n\n## Setup\n\nInstall it. ^install\n\n## Use\n";
let tree = parse_document(note);
let range = tree.section_by_heading_path(&["Guide", "Setup"]).unwrap();
assert_eq!(&tree.body[range], "## Setup\n\nInstall it. ^install");
assert_eq!(select::block(&tree.body, "install").as_deref(), Some("Install it."));
assert_eq!(tree.block_by_id("install").unwrap().range(), tree.root.sections[0].blocks[0].range());
```
The document tree is a different view: there the first H1 is the title, not
a section. Its `SectionNode::range` and `BlockNode::range()` are byte ranges
in `DocTree::body` too.
## Parity with the TypeScript package
Both packages are checked against one corpus of contracts, documents and
expected findings: `packages/ts/markdown-contract/corpus/` in the
[osdlc/osdlc](https://github.com/osdlc/osdlc) repository. This crate's
`tests/corpus.rs` loads each shared contract, validates each document and
compares the findings with the expected ones, as the TypeScript test does:
- the number of findings;
- each finding id, in order;
- the level, line and hint, where the expected file records them.
That is the whole claim. Message text, columns, `fix` edits and findings on
documents outside the corpus are not checked. Fixtures that need custom `rule`
or `docRule` functions cannot be written in YAML, so the corpus marks them
TypeScript-only and the crate skips them.
The crate and the package are versioned separately: crate 0.4.0 is checked
against the corpus of `@osdlc/markdown-contract` 0.3. `comrak` is
pinned to an exact version: the line numbers come from its parser, and a
release of `comrak` can move them.
### Regex dialect
A text rule's `regex` and a frontmatter string's `pattern` are compiled with
the [`regex`](https://docs.rs/regex) crate, not as JavaScript regexes:
- lookaround (`(?=`, `(?!`, `(?<=`, `(?<!`) and backreferences are not
supported;
- `\w`, `\d` and `\b` match Unicode letters and digits, where JavaScript
matches ASCII only;
- a regex matches code points, where the TypeScript package (no `u` flag)
matches UTF-16 units. Outside the Basic Multilingual Plane, `.` and a
negated class match a whole character where JavaScript matches half of it,
and an empty match before such a character is counted once where
JavaScript counts it twice;
- `ignoreCase` folds case by Unicode rules, so `k` also matches the Kelvin
sign (U+212A). JavaScript without the `u` flag does not.
`load_contract` rejects a source the crate cannot compile and names its key.
For a text rule's `regex`, the TypeScript package instead accepts the
contract and throws when it validates.
A text rule's `pattern` is a phrase, not a regex, so it does not depend on
this dialect. Nor do the string `format`s or the whitespace rules (`\s` and
trimming), which follow JavaScript.
### What the crate does not share with the TypeScript package
- No Zod and no typed frontmatter model. The schema runtime covers the JSON
Schema subset that `mcVersion: 2` contracts use.
- No `read`, `inferConfig`, `migrate` or command-line tool.
- `load_config` takes a resolver for the contract files it names, so it does
no I/O; `load_config_file` (with `native`) reads them from disk.
- Frontmatter YAML is parsed with `serde_yaml`, not the `yaml` package. A
document whose frontmatter does not parse gets one `frontmatter/yaml`
finding, where the TypeScript package can report several. Its message text
differs, and its line can differ in cases the corpus does not cover.
- A frontmatter finding on a value inside a flow collection (`{a: 1}`,
`[1, 2]`) has no line, and unknown keys in a flow mapping are reported in
sorted order rather than document order.
- A `gap` bound must be a non-negative integer. The TypeScript package accepts
any number.
- Contract load errors carry the same paths but not always the same text.
An unknown key gets no "did you mean" suggestion.
- Globs in a config cover the common subset of picomatch (`*`, `**`, `?`,
`[...]`, `{a,b}`), through `globset`.
## Stability
The crate stays on 0.x for now. A breaking change ships only in a new 0.x
minor release, with a CHANGELOG entry.
## License
Licensed under either of [Apache License, Version 2.0](LICENSE-APACHE) or
[MIT License](LICENSE-MIT), at your option.