cindermark 0.2.0

High-performance incremental Markdown parser with UTF-16 offsets, built for native text editors on Apple platforms
Documentation
# Cindermark Performance

## Methodology

All numbers come from `cargo bench` (criterion 0.5) with the release profile
(`lto = "fat"`, `codegen-units = 1`). Benchmarks generate synthetic notes with
a realistic block mix (headings, paragraphs, lists, checkboxes, code blocks).

Two families of benchmarks matter, and they answer different questions:

- **Full parse** (`parse_500_lines`, `parse_2500_lines`,
  `parse_editable_500_lines`) — the cost of opening a document or of the
  incremental path's full-reparse fallback.
- **Incremental keystroke** (`incremental_keystroke_*`,
  `incremental_with_stats_2500`) — the cost the editor actually pays per
  debounced edit while typing. This is the hot path.

Run them yourself:

```bash
cargo bench
```

Numbers below are from x86_64 Linux and Apple Silicon (M-series);
relative deltas are what matter.

## Current numbers

<!-- Updated by perf commits; see git history for per-change deltas. -->

| Benchmark | x86_64 Linux | Apple Silicon | vs. pre-clone-elimination (Linux) |
|---|---|---|---|
| parse_500_lines | ~1.30 ms | ~666 µs ||
| parse_2500_lines | ~7.3 ms | ~3.2 ms ||
| parse_editable_500_lines | ~1.39 ms | ~809 µs | −7% |
| incremental_keystroke_500 | ~255 µs | ~117 µs | −33% |
| incremental_keystroke_2500 | ~1.30 ms | ~562 µs | −34% |
| incremental_keystroke_10k | ~8.7 ms | ~2.3 ms | −14% |
| incremental_with_stats_2500 | ~3.85 ms | ~1.96 ms | −28% |

## What the FFI boundary costs

UniFFI serializes every returned value into a `RustBuffer` that Swift then
lifts into native types. That means:

1. Every `String` in the result is copied at the boundary regardless of how
   the Rust side produced it.
2. The dominant *avoidable* costs on the Rust side are extra full passes over
   the block list before conversion.

The incremental FFI paths used to make up to three such passes per call
(a snapshot clone, a second clone into a temporary document for stats, and
the FFI conversion pass). The conversion pass is required; the clones were
not, and have been eliminated — stats and conversion now borrow the block
list, which is then *moved* (not copied) into the parser's snapshot state.

## Deliberately deferred optimizations

### Borrowed / interned AST strings

`BlockKind` variants own their `String` data (heading text, paragraph text,
table cells, list-marker metadata). A borrowed-AST refactor (`&str` +
lifetimes, or `Arc<str>` interning) would eliminate those allocations during
parsing.

Deferred because:

- UniFFI copies every string into a `RustBuffer` at the FFI boundary anyway,
  so for FFI consumers the win is only the *intermediate* copy, not the
  boundary copy.
- A lifetime-carrying AST ripples through all ~9,400 LOC including the
  incremental engine, which stores blocks in a long-lived snapshot — that
  snapshot would still need owned data, forcing either `Arc<str>` everywhere
  or a copy at snapshot time (which is what happens today, once, via move).
- Benchmarks should drive it: revisit if profiling shows allocation in
  `parser::parse` dominating over lexing + inline scanning.

### Incremental stats

`compute_stats` is O(document) per call (grapheme iteration + byte scan). It
is already skipped on the hot styling path (`*_style_only`); the full variant
runs on the host app's slower content-sync cadence (~1.2 s in Ember Notes).
Making stats incremental (per-block deltas) is possible but adds bookkeeping
complexity for a path that is not latency-critical. Revisit if a host app
needs per-keystroke stats.

### Viewport-aware restyling

Beyond the parser: the biggest remaining editor-side win for very large
documents is restyling only the visible viewport instead of the full dirty
range. That work lives in the host app's text-view layer, not in Cindermark —
the parser already returns the dirty block range needed to drive it.