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_contractcompiles a YAML contract (mcVersion: 2) andvalidatechecks a document against it.validatedoes not fail on a bad document; it returns the findings, sorted by line.- A contract can also be built in code with
Contractand the builderssection,sections,optional,one_of,gapandrule. load_configandrun_corpusroute the files of a folder to contracts by glob and return the findings and an exit code (0 when no finding has levelerror, 1 otherwise).parse_documentgives the document tree the checks read. Every section and block carries its byte range in the body.selectpicks part of a note the way an Obsidian link fragment does:#Heading,#Parent#Childand#^block-id.- The crate never changes the documents it checks.
This is the Rust twin of the npm 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
for exactly what it checks.
Install
Cargo features
-
native(on by default): the code that reads files.load_contract_file,load_config_file,walk_dirandrun_corpus_dir. Without it the crate does no I/O: every function takes strings. Build withdefault-features = falseforwasm32-unknown-unknown:= { = "0.4", = false }
Quick start
use ;
let contract = load_contract?;
let document = "---\nstatus: done\n---\n\n## Context\n\nConfig is checked by hand.\n";
let findings = validate;
// `status: done` is not in the enum, and the Decision section is missing.
assert_eq!;
assert_eq!;
assert_eq!;
assert_eq!;
assert!;
# Ok::
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.
use ;
let note = "---\ntitle: x\n---\n# Guide\n\n## Setup\n\nInstall it. ^install\n\n## Use\n";
let tree = parse_document;
let range = tree.section_by_heading_path.unwrap;
assert_eq!;
assert_eq!;
assert_eq!;
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 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 crate, not as JavaScript regexes:
- lookaround (
(?=,(?!,(?<=,(?<!) and backreferences are not supported; \w,\dand\bmatch Unicode letters and digits, where JavaScript matches ASCII only;- a regex matches code points, where the TypeScript package (no
uflag) 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; ignoreCasefolds case by Unicode rules, sokalso matches the Kelvin sign (U+212A). JavaScript without theuflag 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 formats 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: 2contracts use. - No
read,inferConfig,migrateor command-line tool. load_configtakes a resolver for the contract files it names, so it does no I/O;load_config_file(withnative) reads them from disk.- Frontmatter YAML is parsed with
serde_yaml, not theyamlpackage. A document whose frontmatter does not parse gets onefrontmatter/yamlfinding, 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
gapbound 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}), throughglobset.
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 or MIT License, at your option.