markdown-contract 0.4.0

Contracts for markdown documents: declare the frontmatter, headings, tables and lists a document must have, and get findings with line numbers. The Rust twin of @osdlc/markdown-contract.
Documentation

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. 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 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:

    markdown-contract = { version = "0.4", default-features = false }
    

Quick start

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");
assert!(findings.iter().all(|f| f.level == FindingLevel::Error));
# Ok::<(), markdown_contract::DeclarativeError>(())

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 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 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, \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 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: 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 or MIT License, at your option.