A high-performance incremental Markdown parser for native text editors, written in Rust.
Cindermark powers the live Markdown editor in Ember Notes. It exposes source ranges and incremental updates for native editors and Rust consumers.
Most Markdown parsers are built for rendering documents. Cindermark is built for editing them:
- UTF-16 offsets. Block and inline ranges are available in TextKit's coordinate system. Rust block nodes also retain UTF-8 byte ranges; see the compatibility profile before slicing strings.
- Incremental re-parsing. Local edits can re-parse a dirty region and shift unaffected blocks. Structural boundaries can require a full parse; the result identifies the block range to restyle.
- Document metadata. Parsing produces the block and inline AST, document stats, wiki links and headings.
- Swift bindings. Generated with UniFFI, with native libraries built from the same interface definition.
- Tiny dependency tree. The default build depends on exactly three crates —
memchr,rustc-hash,unicode-segmentation. UniFFI is compiled only when you opt into theffifeature for Swift bindings, so a pure-Rustcargo add cindermarkstays lean. (See Feature flags.)
CommonMark 0.31.2-oriented syntax with explicit extensions. This is not a claim of complete CommonMark or GFM conformance; see the compatibility profile.
| Category | Supported |
|---|---|
| Blocks | Headings, paragraphs, fenced code blocks (with language), blockquotes, bullet/ordered lists (nested), task lists / checkboxes, tables (with alignment), horizontal rules, footnote definitions, callouts, Mermaid diagrams (typed) |
| Inline | Bold, italic, bold-italic (delimiter runs with Unicode flanking), strikethrough, inline code (multi-backtick), links, autolinks (bare URLs, domains, emails, subreddits), wiki links [[...]], highlights ==...== (plus colored/hex variants), underline (<u>/tilde), footnote refs, hex color literals, comments |
| Editor extras | Document stats as a parse byproduct, wiki-link extraction, heading outline extraction, checkbox toggling, plain-text preview rendering with span ranges, configurable image-marker URI scheme for attachment placeholders |
New in 0.2.0: nested lists — bullets, ordered lists, and checkboxes indented for nesting (up to 32 tab-expanded columns) now parse as nested items instead of degrading to indented code (column-based; see Known limitations) — plus a WebAssembly build (wasm feature) that powers the live browser playground.
New in 0.3.0: source-ranged inline/display/fenced math, ++ underline,
table-cell spans, list-subtree ranges and resource references. See the
migration guide for breaking Rust and binding changes.
The test suite includes incremental/full-parse parity checks and malformed-input properties. Version 0.3.0 is available on crates.io and through Swift Package Manager with optimized Apple binaries. WASM remains a build-from-source target.
.package(url: "https://github.com/renedeanda/cindermark", from: "0.3.0")import Cindermark
let parser = CindermarkParser()
// Or opt in to the attachment-marker extension with your own URI scheme,
// so `` lines parse as ImageMarker blocks:
// let parser = CindermarkParser(imageMarkerScheme: "myapp:")
let result = parser.parseEditable(text: markdown)
for block in result.blocks {
// block.utf16Start / block.utf16End map directly onto NSTextStorage
// block.inlineSpans carry per-span UTF-16 ranges for styling
}For live editing, feed edits to the incremental API and restyle only the dirty range:
let update = parser.parseEditableIncrementalStyleOnly(
text: newText,
editUtf16Start: editStart,
editOldUtf16Len: oldLen,
editNewUtf16Len: newLen
)
// Restyle only blocks in update.dirtyStart..<update.dirtyEndNote: the SwiftPM binary target resolves for tagged releases. If you're building from an untagged checkout, use
build-apple.shbelow instead.
Most apps should use Swift Package Manager above. This path is for building directly from source — first-party integrations, contributors, or building from an untagged commit with custom flags. Ember Notes consumes Cindermark as a git submodule and links the static library directly:
git submodule add https://github.com/renedeanda/cindermark
cd cindermark
./build-apple.sh release --out-dir "$YOUR_PROJECT/Parser"This drops libcindermark.a (per-SDK: device / simulator / macOS), the generated CindermarkFFI.swift, and the CindermarkFFIFFI module header into your integration directory. Point LIBRARY_SEARCH_PATHS at the per-SDK dirs, add -lcindermark to OTHER_LDFLAGS, and include the generated Swift file in your target.
[dependencies]
cindermark = "0.3"use cindermark::CindermarkParser;
// Pass None to disable attachment markers, or Some("myapp:".into()) to
// enable the attachment-marker extension.
let parser = CindermarkParser::new(None);
let result = parser.parse("# Hello\n\nSome **bold** text.".to_string());The full parser API — parse, parse_editable, the incremental methods, stats,
wiki-link and heading extraction — is available on the default build with no
feature flags and no UniFFI dependency.
Cindermark ships a pure-Rust parser by default and keeps everything Swift/UniFFI-related opt-in, so Rust consumers never pay for the bindings toolchain:
| Feature | Default | What it adds |
|---|---|---|
| (none) | ✅ | The parser itself. Three dependencies (memchr, rustc-hash, unicode-segmentation), no build-script codegen. |
ffi |
UniFFI scaffolding for the Swift/Apple bindings. Enabled automatically by build-apple.sh and the release workflow. |
|
bindgen |
The uniffi-bindgen CLI used to regenerate CindermarkFFI.swift from cindermark.udl. Only the bundled binary needs it (implies ffi). |
|
wasm |
A wasm-bindgen surface for the browser demo (independent of ffi). |
CI builds and tests both the default and the ffi/bindgen configurations on
every change, so neither path can regress.
Numbers from cargo bench (criterion); release profile with fat LTO. See docs/PERFORMANCE.md for methodology.
| Benchmark | Apple Silicon | x86_64 Linux |
|---|---|---|
| Incremental keystroke, 500-line note | ~117 µs | ~255 µs |
| Incremental keystroke, 2,500-line note | ~562 µs | ~1.3 ms |
| Incremental keystroke, 10,000-line note | ~2.3 ms | ~8.7 ms |
| Full parse, 500-line note | ~666 µs | ~1.3 ms |
| Full parse, 2,500-line note | ~3.2 ms | ~7.3 ms |
The design targets the editor's real budget: a debounced keystroke on a large document must cost single-digit milliseconds, and it does — even at 10,000 lines.
src/
├── lexer.rs # UTF-8 byte scanner + block tokenizer (memchr-accelerated)
├── parser.rs # Single-pass block parser (grouped + editable modes)
├── inline.rs # CommonMark inline spans: delimiter-run emphasis, links,
│ # autolinks, highlights, wiki links, code, footnotes…
├── incremental.rs # Dirty-block detection + partial re-parse + offset shifting
├── ast.rs # Block + inline AST node types
├── utf16.rs # UTF-8 → UTF-16 offset mapping (O(1) ASCII fast path)
├── lib.rs # UniFFI FFI layer: CindermarkParser object + Ffi* types
└── cindermark.udl # UniFFI interface definition
Design notes:
- Editable vs grouped mode. Grouped mode merges list items into list blocks (for rendering); editable mode keeps every line's block separate (for per-line editor styling).
- Incremental strategy. Edits are located by binary search over block UTF-16 ranges and expanded for boundary effects. Fences, tables, math and raw HTML can require conservative full reparsing; see the compatibility profile.
- Panic boundary. Release builds retain unwinding for UniFFI's panic boundary. This does not make allocation failure, process aborts or all host-language failures recoverable.
cargo test # full suite, any platform
cargo bench # criterion benchmarks
./build-apple.sh # Apple static libs + Swift bindings (requires macOS + rustup Apple targets)Nested lists are column-based, not CommonMark container-based:
- A list/checkbox marker may be indented up to 32 tab-expanded columns
(tab = next multiple-of-4 column) and always parses as a list item —
nesting depth is the count of leading whitespace characters (a tab
counts as one toward depth, though it expands to a multiple-of-4 column
for the 32-column cap), not the CommonMark "marker width + 1 relative
to the parent" rule. This keeps every line's classification local
(required for incremental parity) at the cost of §4.4 fidelity: a
4-space-indented
- itemis a nested list item here, never an indented code block. Indented lines without a list marker still parse as indented code. - Loose vs. tight lists are not distinguished; blank lines always terminate a list run.
- Continuation paragraphs inside a list item (a following line indented to the item's content column) are not supported — in grouped mode the line is appended to the previous item's text, in editable mode it parses as its own paragraph/code block.
- Grouped mode flattens a nested same-marker list into a single list and does not preserve per-item depth. Reconstructable nesting is an editable-mode feature — there, each item keeps its own indent.
Good first issues:
***text***at line start is ambiguous with thematic breaks in some edit sequences.- Emphasis wrapping a code span (e.g.
**`code`**) drops the outer bold/italic: the code span claims its byte range and the overlapping emphasis is discarded (in both parse modes).
Shipping Cindermark in something? Add your project — open a PR appending a line to this list:
- Ember Notes — the native iOS/macOS notes app Cindermark was extracted from.
Contributions are welcome — Cindermark is small and focused, and changes that keep it that way especially so.
- 🐛 Found a bug or want a feature? Open an issue.
- 💬 Questions, ideas, or built something cool? Start a discussion — I'd genuinely love to see what you make.
- 🛠️ Contributing code? See CONTRIBUTING.md for the dev setup and the pre-PR checklist.
MIT © René DeAnda.