The frontmatter module is an internal implementation detail of mdtablefix.
Its visibility is restricted to pub(crate) in the library so the crate does
not expose YAML frontmatter parsing as part of its supported public API.
This boundary matches the role of the module. The helper exists to shield a leading YAML frontmatter block from Markdown transforms, including CLI-only operations such as list renumbering and thematic break normalization. External callers interact with that behaviour through higher-level formatting entry points rather than by calling the frontmatter helper directly.
- Keep the public API focused on stable formatting operations rather than document pre-processing internals.
- Reduce the risk of accidental API commitments around a narrowly scoped helper that may need to change with the processing pipeline.
- Make the intended layering explicit: frontmatter detection supports stream processing, but it is not a general-purpose parsing API.
process_with_frontmatter is the single authoritative
boundary for splitting and rejoining leading YAML frontmatter. It never passes
the frontmatter prefix to body_fn, and it prepends that prefix verbatim to
the closure's output.
Callers must not split, transform, or restore frontmatter outside this
boundary. Both process_stream_opts in the library and
process_lines in the package binary route through it. All body transforms
must run inside the closure, including CLI-only transforms such as
renumber_lists and format_breaks.
When working in this area:
- Prefer wiring new behaviour through
process_with_frontmatterrather than exporting frontmatter helpers. - Treat changes to frontmatter parsing rules as internal architectural changes that should update this guide and any affected behaviour documentation.
- Keep the split/rejoin contract and its tests in sync with changes to
src/frontmatter.rs.
The table reflow pipeline is split into small stages so continuation rows and separator rows can be handled without losing column structure.
protect_leading_empty_cells rewrites leading empty continuation cells to a
marker before parsing. parse_rows preserves physical source-line boundaries
and delegates logical-row recovery to src/reflow/row_parsing.rs. That module
infers the expected table width, recognizes complete legacy rows concatenated
on one physical line, and retains padded or trailing empty cells as cell data.
clean_rows restores the markers to empty strings and removes rows that are
entirely empty.
calculate_widths measures each column using Unicode display width so the
formatter sizes columns according to the glyphs that will actually be emitted.
format_rows applies escaping and padding to each cell, and insert_separator
restores the separator row with widths derived from the final table body.
Makefile:
check-static-regexes: Runs before Clippy as part ofmake lintand uses ripgrep (rg) to reject hand-rolled static regular expression declarations. The scan lives inscripts/check-static-regexes.shand rejects anystaticthat wrapsRegex::newdirectly in a supported lazy-wrapper constructor —std::sync::LazyLock::neworonce_cell::sync::Lazy::new, whether spelled directly or fully qualified — solazy_regex!remains the sole sanctioned idiom.tests/static_regex_lint.rsexercises every supported form. Contributors must install ripgrep locally; Continuous Integration (CI) installs the pinned version before running the lint gate.
src/lib.rs:
lazy_regex!: Declares every static regular expression through a singleLazyLock<Regex>initialization idiom. New static regular expressions must use this macro and supply a descriptive expect message that identifies the pattern whose compilation failed.
src/textproc.rs:
leading_indent(s: &str) -> &str: Returns the leading whitespace prefix ofsas a borrowed slice. Whitespace followschar::is_whitespace, so Unicode whitespace and tabs are included. It returns an empty string whenshas no leading whitespace and returns all ofswhen every character is whitespace. External callers may rely on this public helper for that Unicode-prefix contract, composing with its borrowed result and callingto_string()only when storage or an owned return is required. New indent extraction must reuse this helper rather than duplicating its logic, except Markdown block-indentation checks:src/wrap/block.rsowns those checks with its crate-privateleading_indent, which recognizes only spaces and tabs and returns both column width and byte offset.
src/main.rs file-output functions:
open_file_parent(path) -> anyhow::Result<(Dir, Utf8PathBuf)>is the CLI's sole ambient filesystem boundary. It opens the selected file's parent directory and returns a relative UTF-8 path for capability-scoped handling.format_to_string(directory, path, opts) -> anyhow::Result<String>reads and formats a file throughcap_std::fs_utf8::Dirwithout modifying it. Its returned text uses the same trailing-newline convention as a rewritten file.rewrite_in_place(directory, path, opts) -> anyhow::Result<()>reads and formats a capability-scoped file, then writes the formatted text back through the same directory capability.
Callers select the function that matches their intent rather than passing a
Boolean mode flag. This keeps stdout and in-place contracts explicit while
allowing both paths to share the exact formatting result. New file-output call
sites must receive a directory capability and relative camino::Utf8Path
rather than performing ambient filesystem access themselves.
src/reflow.rs:
parse_rows: Parses trimmed table lines into row vectors while preserving continuation-row boundaries.clean_rows: Restores continuation markers to empty strings and drops rows that contain no cell content.calculate_widths: Computes the emitted display width required for each output column.format_rows: Escapes literal pipes, pads cells to the computed widths, and emits aligned table lines.insert_separator: Reinserts a formatted separator row after the header when one was parsed or promoted.detect_separator: Chooses the separator source, preferring an explicit separator line and otherwise promoting the second parsed row when valid.
src/reflow/row_parsing.rs:
cell_is_semantically_empty: Treats both an empty string and the private leading-cell marker as empty parser content.split_physical_rows: Recovers complete logical rows from each physical row using the inferred width, without treating padded cell delimiters as row boundaries.infer_expected_width: Derives the logical column count from the first row and complete legacy concatenations, including embedded separator rows.
src/table.rs:
format_separator_cells: Expands separator cells to the target widths while preserving Markdown alignment markers.
src/process/buffer.rs:
ProcessBuffer: Owns the stream-processing output buffer, the pending table run, and the table-mode state forprocess_stream_inner. The parent process module is responsible for orchestration; the buffer owns the boundary rules that decide when fence lines pass through verbatim, when a pipe-led row starts table mode, and when a new Markdown block flushes a pending table before the block line is processed. Debug instrumentation in this module must not log raw Markdown lines; use bounded fields such as line lengths and buffer counts.
src/footnotes/renumber/definitions.rs:
collect_definition_updates: Scans lines for footnote definitions and promotable numeric-list candidates, assigns sequential numbers, and rewrites inline references within definition bodies to produce the definition rewrite plan.definition_segment_end: Returns the exclusive end row of a definition segment, absorbing continuation and connector-blank lines up to the block end. The siblingreordermodule shares this boundary primitive so scanning and reordering compute definition boundaries identically.
src/footnotes/renumber/reorder.rs:
reorder_definition_block: Reorders the final footnote-definition block according to the numbering plan built by the siblingdefinitionsmodule. It keeps continuation lines attached to their definition, preserves block prefixes, migrates leading separator blanks at the first segment boundary, and skips mutation with a warning if the composed block would change row count.
The reorder module was extracted from definitions during the module-size
refactoring audit covering issues #357–#367 in PR #368.
src/wrap/tokenize/scanning.rs:
scan_code_suffix_end(text: &str, start: usize) -> usizeadvancesstartpast any recognized inflectional or possessive suffix that is directly attached to a closing inline-code fence at that position. Recognized shapes are: a hyphenated compound where the word after the hyphen starts with a lowercase letter (e.g.-style), a possessive's, and bare alphabetic suffixes (s,ed,ing, and any other run of ASCII letters). Returns the originalstartwhen no suffix is recognized.
src/wrap/inline/fragment.rs:
has_inline_code_structure(text: &str) -> boolreturnstruewhentextbegins with a backtick fence (optionally preceded by an opening bracket or punctuation) and contains a corresponding closing fence, with or without a trailing inflectional suffix. Used byclassify_fragmentandinline.rsto identify combined code+suffix tokens as atomic inline code.
HTML table conversion uses html5ever for parsing and markup5ever_rcdom for
the temporary DOM sink. These crates must stay on the same markup5ever parser
stack because RcDom implements the TreeSink trait from that shared
dependency line. If html5ever is upgraded, update markup5ever_rcdom in the
same change and run the compile-time parser integration test before merging.
The manifest uses caret requirements rather than exact pins, so compatible patch updates remain available. The lockfile records the concrete crate release selected for the branch.
src/fences.rs exposes the preprocessing helpers used by the --fences option.
compress_fences(lines: &[String]) -> Vec<String>performsFenceTracker-driven conditional rewriting. For each matched fenced block, it determines whether normalizing the outer delimiter would make an inner fence line structural. This covers same-marker inner fences and the cross-marker case where an inner backtick fence would become structural after an outer tilde fence is converted to backticks. If so, it preserves the original outer delimiter width and marker family. Unmatched or malformed delimiter runs fall through the legacy stateless normalization path.attach_orphan_specifiers(lines: &[String]) -> Vec<String>attaches a lone language identifier line to the following unlabelled fence, but only when the scanner is outside any active fenced block. It usesFenceTrackerto skip attachment for specifier-like lines and target fences that appear inside an open block, including nested cases preserved bycompress_fences.
Both functions reuse FenceTracker from
src/wrap/fence.rs for structural fence detection. This
keeps preprocessing semantics consistent with the wrapping pipeline. See
docs/architecture.md for the processing pipeline context.
The rationale for the staged table reflow pipeline is recorded in
docs/adrs/0001-table-reflow-pipeline.md. Refer to that ADR when changing the
parse, width-calculation, or separator-handling flow so implementation changes
stay aligned with the documented design constraints.
The rationale for treating date-like prose sequences as atomic inline fragments
is recorded in docs/adrs/0003-date-sequences-as-inline-fragments.md. Refer to
that ADR before adding new date forms or changing the span-grouping boundary.
The wrapping pipeline for --wrap is:
wrap_text parses each leading blockquote with BlockquotePrefix before any
block classification. The abstraction exposes the source prefix, nesting depth,
and stripped inner content: downstream classification and prefix-aware wrapping
receive the inner content, while emitted lines retain the source prefix.
FenceTracker receives the same inner content and depth. An open fence closes
when a compatible marker is observed at its opening depth, or implicitly when
the current depth drops below that opening depth (the depth < open_depth
contract). A transition from depth 3 to depth 2 therefore retains a fence
opened at depth 2; it closes only once the depth falls below 2. Processing
stages that loop over raw Markdown use the crate-private observe_source_line
helper. It parses BlockquotePrefix once and returns the fence state before
observation, whether the line is a fence marker, and the resulting state. The
public observe_line and in_fence_for_line compatibility helpers remain for
callers that need one of those individual operations; they apply the same
depth-aware tracking.
-
Block classification.
classify_blockinsrc/wrap/block.rsinspects each stripped inner line and decides whether it should pass through verbatim or enter the paragraph wrapper.wrap_textinjects a shared [LinkReferenceMatcher] into each call. Fenced code blocks, indented code blocks, headings, tables, directives, link reference definitions, and blank lines stop paragraph accumulation. -
Prefix-aware paragraph handling.
ParagraphWriterinsrc/wrap/paragraph.rsis the single entry point for prefix-aware wrapping.wrap_with_prefixcomputes the available content width once from the Unicode display width of the first-line prefix, then feeds the paragraph text intowrap_preserving_code.ParagraphStateowns the ordinary paragraph segments, their shared indentation, any remembered continuation indentation, and an optional deferredPendingPrefix.Pending prefix deferral. When
handle_prefix_lineprocesses a line whose text contains an unclosed inline code span (checked byhas_unclosed_code_span), it clears the current paragraph buffer and saves the prefix, rest text, available width,repeat_prefix, andhard_breakflag intoParagraphState::pending_prefixas aPendingPrefixvalue rather than wrapping immediately.PendingPrefixalso records original source lines for ambiguity-preserving passthrough, andsynthetic_join_spacesstores byte offsets for spaces inserted by continuation joining so only formatter-created code-span edge spaces are trimmed later. Subsequent source lines are routed throughhandle_pending_continuation(insrc/wrap.rs) instead of the normal wrapping path.handle_pending_continuationclassifies the line and delegates each soft-wrapped continuation chunk toapply_continuation_chunkinsrc/wrap/continuation.rs, the module that owns the join/update/dispatch state machine. Each continuation is joined ontopending_prefix.restviajoin_pending_continuation, which inserts a space unless the continuation begins with the exact matching closing fence (detected bycontinuation_begins_with_closing_fence). Blockquote continuations are only joined when their prefix exactly matches the pending prefix. After joining,apply_continuation_chunkconsultsupdate_span_stateto drive aSpanStateUpdate(StillOpen,ClosedAndReopened, orFlush); when the same chunk both closes the pre-existing span and opens a new one, the helper emits the closed prefix segment and keeps the new span pending rather than inventing a closing fence.ParagraphState::drain_pending_prefixtakes that pending segment and clears the regular paragraph buffers before final emission.PendingPrefix::used_prefixtracks whether the original prefix has already been emitted, andpending_prefix_for_next_segmentuses it to give the first split segment the original prefix and later split segments the continuation indent.TailReflowrecords whether prose after a resolved span may remain buffered for greedy reflow or must flush after an ambiguous close-and-reopen transition. If the opener is at or near the end of its source line,PendingPrefixmarks subsequent continuations as verbatim, so joining does not create leading or trailing spaces inside the code span. When the projected join would exceed the available content width, the pending line and continuation retain conforming authored boundaries inside that span rather than forming a Markdownlint-invalid overlong line. Otherwise,flush_paragraphpasses resolved content through the ordinary wrapper so prose after the span is reflowed in the same pass. The exception isContinuationMode::VerbatimFlush: when the scanner sees a closing fence immediately followed by a word character,flush_paragraphemitspending.original_linesverbatim instead of rewrapping the buffer. Whenhard_breakis set, two trailing spaces are appended to the last emitted line.clear()onParagraphStatealso resetspending_prefixtoNone.ContinuationModerecords how pending continuations are handled:Normalizeuses ordinary Markdown soft-break spacing,TightCodeSpansuppresses synthetic spaces after an opener at end-of-line, andVerbatimFlushpreservespending.original_linesfor ambiguous close and reopen sequences. Thecode_span_trimmodule containstrim_code_span_edge_spaces, which matches code spans by exact fence length and removes only spaces whose byte offsets appear insynthetic_join_spaces. Thespanning_codemodule handles ordinary paragraphs whose code span crosses a source boundary: when the joined span is overlong but every source line conforms, it retains only boundaries inside that span, restores the paragraph indentation, and leaves surrounding prose to the greedy wrapper. Trace events record width-triggered preservation, prefix-mismatch flushes, andTailReflowtransitions at these non-obvious decision boundaries. -
Fragment construction and line fitting.
wrap_preserving_codeinsrc/wrap/inline.rstokenizes prose withtokenize::segment_inline, groups the tokens intoInlineFragmentvalues viadetermine_token_span, and callstextwrap::wrap_algorithms::wrap_first_fitover the accumulated fragment buffer. Token predicates insrc/wrap/inline/predicates.rsclassify punctuation, links, code spans, and footnote markers. Span grouping helpers insrc/wrap/inline/span_helpers.rsextend grouped spans over trailing punctuation, couple adjacent footnote references, and merge chained inline code or link tokens.determine_token_spanforward-couples opening punctuation tokens ((,[, and CJK openers) and hyphen-prefix tokens to the next inline code span or Markdown link so wrapping never leaves a lone opener or prefix at the end of a line.try_couple_inline_link_after_openerapplies the same rule to parenthesized inline citation links such as([1](url)), grouping the opener and link as oneSpanKind::Linkso adjacent citations like([1](url))([2](url2))do not split at the boundary. At the tokeniser level,segment_inlinealso stops trailing-punctuation and plain-text scans at an unescaped([boundary viascan_trailing_punctuation_endandscan_plain_text_end, both usingstarts_inline_citation, so the citation opener(is emitted as its own token instead of being swallowed into the preceding token's punctuation cluster. That boundary givesdetermine_token_spanandtry_couple_inline_link_after_openera clean opener token to couple with the following inline link, making the full([n](url))span atomic, while escaped sequences such as\([bypass the early exit and remain plain text.normalize_footnote_ref_spacinginsrc/wrap/inline/normalize.rsthen removes whitespace tokens between trailing punctuation and inline GFM footnote references before fragment construction, while leaving footnote definition starts ([^label]:) untouched. Trailing punctuation after atomic spans is grouped in the same pass, and GFM footnote references that immediately follow inline code or links (including opener-coupled spans) stay attached to the preceding punctuation cluster. Date-component predicates are applied bytry_match_date_sequenceinspan_helpers.rsbeforedetermine_token_spanperforms the standard punctuation and link grouping pass. -
Post-processing and rendering. The
postprocessmodule appliesmerge_whitespace_only_linesand thenrebalance_atomic_tailsso whitespace-only wrap artefacts and isolated tails are normalized before the fragments are rendered back into output lines.wrap_preserving_codepasses its configured wrap width intomerge_whitespace_only_lines; that pass must compare any projected inline-code tail carry against the same width before moving an atomic code span onto a following content line.render_lineinsrc/wrap/inline.rsconverts each finished fragment line into Markdown text. Itsstrip_leading_carry_whitespaceflag removes carry whitespace that the fitter attaches to the start of wrapped continuation lines; it is set only whenwrap_preserving_codehas already emitted at least one line, so intentional leading whitespace on the first output line is preserved. Non-final lines may also drop a single trailing space unless the line ends with a hard-break double space.
BlockKind::LinkReferenceDefinition
Classified when indentation is fewer than four columns and
LinkReferenceMatcher::is_definition matches the line.
LinkReferenceMatcher
Centralizes link reference regex access. production() returns the workspace
matcher; callers inject &LinkReferenceMatcher (or a copy) into query methods
rather than reading global statics directly. is_definition(line) classifies
complete link reference definitions. is_bare_label_only(line) classifies
split definitions whose destination may follow on the next line.
is_url_continuation_line(line) accepts indented destination continuation
lines with an optional inline title, but rejects indented Markdown-prefixed
blocks so lists and blockquotes still use normal block handling.
standalone_title_need(line) returns None when the line is not a definition,
Some(true) when no inline title is present, and Some(false) when a title is
already on the same line. is_standalone_title_line(line) matches title
continuation lines per CommonMark spec §4.7 (at most three leading spaces,
title in "…", '…', or (…)). Known limitation: nested or escaped brackets
inside link labels are not supported (for example, [label [nested]] or
[\[escaped\]]).
LinkTitleWindow
Explicit state for link reference continuations in wrap_text and
replace_ellipsis. Starts Closed. Callers pass each classified definition to
observe_definition(line, matcher), which opens AwaitingStandaloneTitle for
a bare definition or AwaitingUrlContinuation for a label-only definition.
While a title is expected, observe_next_line(line, matcher) returns
Some(EmitVerbatim) for blank or title lines (and closes the window), or
Some(Reprocess) when the line is ordinary prose (closing the window, so the
caller processes it normally).
After a label-only reference definition, observe_bare_label() opens
AwaitingUrlContinuation. A valid indented destination emits verbatim; if the
destination does not include an inline title, the window advances to
AwaitingStandaloneTitle so the following line may still be a standalone
title. Blank lines and inline-title destinations close the window. Markdown
prefixed blocks return Reprocess, close the window, and allow normal block
classification to handle the line. Fence entry calls observe_fence_context()
to reset the window.
InlineFragment carries the rendered fragment text, its precomputed display
width, and a FragmentKind tag. That construction-time classification lets the
is_whitespace, is_atomic, and is_plain predicates answer all later
questions without repeating ad hoc string inspection in the post-processing
passes. Inline code spans, Markdown links, and GFM footnote references use
atomic fragment kinds, so the wrapper never inserts a break inside their
Markdown syntax.
The inline span builder uses the private is_trailing_punctuation_token
helper, via extend_punctuation, to keep trailing punctuation attached to
links and code spans while token groups are being formed. Markdown delimiters
that open syntax are not treated as trailing punctuation, which avoids
classifying arbitrary ASCII punctuation as link suffixes.
The postprocess module exists because greedy line fitting alone does not
reproduce the repository's historical whitespace semantics. The first pass
merges whitespace-only wrap lines into adjacent content, and the second pass
rebalances a trailing atomic or plain fragment only when the destination line
still fits within the configured width.
Table: Key types and functions.
| Symbol | File |
|---|---|
LinkReferenceMatcher |
src/wrap/link_reference.rs |
LinkTitleWindow |
src/wrap/link_reference.rs |
classify_block |
src/wrap/block.rs |
FragmentKind, InlineFragment |
src/wrap/inline/fragment.rs |
classify_fragment |
src/wrap/inline/fragment.rs |
Character and fragment predicates (is_inline_code_token, looks_like_link, looks_like_footnote_ref, is_month_name, is_ordinal_day, is_numeric_day, is_year, …) |
src/wrap/inline/predicates.rs |
SpanKind, span grouping helpers (merge_code_span, try_couple_footnote_reference, try_match_date_sequence, …) |
src/wrap/inline/span_helpers.rs |
try_couple_inline_link_after_opener |
src/wrap/inline/span_helpers.rs |
normalize_footnote_ref_spacing |
src/wrap/inline/normalize.rs |
build_fragments, wrap_preserving_code, render_line |
src/wrap/inline.rs |
determine_token_span |
src/wrap/inline.rs |
merge_whitespace_only_lines |
src/wrap/inline/postprocess.rs |
rebalance_atomic_tails |
src/wrap/inline/postprocess.rs |
ParagraphWriter, wrap_with_prefix |
src/wrap/paragraph.rs |
ParagraphState, PrefixLine |
src/wrap/paragraph.rs |
PendingPrefix |
src/wrap/paragraph/pending.rs |
ContinuationMode, TailReflow |
src/wrap/paragraph/pending.rs |
conforming_source_lines_for_overlong_span — Retains conforming authored boundaries only inside a cross-line code span that would be overlong when joined. |
src/wrap/paragraph/spanning_code.rs |
emit_pending_with_verbatim_continuation — Emits a pending prefix plus raw continuation for ambiguous inline-code source. |
src/wrap/paragraph.rs |
drain_pending_prefix — Takes the deferred prefixed segment and clears plain paragraph buffers before final emission. |
src/wrap/paragraph.rs |
pending_prefix_for_next_segment — Selects the original pending prefix for the first deferred segment, then the continuation indent for later segments. |
src/wrap/paragraph/pending.rs |
apply_continuation_chunk — Centralized join/update/dispatch entry point that reconciles a single continuation chunk with the active PendingPrefix buffer. |
src/wrap/continuation.rs |
join_pending_continuation |
src/wrap/continuation.rs |
starts_inline_citation |
src/wrap/tokenize/mod.rs |
opening_fence_run_len — Measures the length of an unescaped backtick run at the start of a byte slice; used to identify opening code-span fences. |
src/wrap/tokenize/scanning.rs |
position_after_close(text, search_start, fence_len) — Walks text from absolute byte offset search_start to find the first closing backtick fence of exactly fence_len characters. Escaped closing candidates (backtick runs preceded by an odd number of backslashes) are recorded but skipped; a literal (unescaped) closing fence is returned unless an iterative forward look-ahead confirms that fence is itself the opener of a subsequent balanced span, in which case the earlier escaped candidate is returned instead. Returns None when no matching close exists or fence_len is zero. |
src/wrap/tokenize/scanning.rs |
scan_continuation_span_state — Incrementally scans a continuation string given a known open fence length, returning the remaining open fence length or None when all spans are balanced; used to avoid O(N²) rescanning of the accumulated pending text. |
src/wrap/tokenize/scanning.rs |
handle_backtick_fence — Tokenizes an inline code span from the opening fence byte offset and delegates closing-fence detection to position_after_close. |
src/wrap/tokenize/parsing.rs |
handle_pending_continuation |
src/wrap.rs |
scan_code_suffix_end |
src/wrap/tokenize/scanning.rs |
has_inline_code_structure |
src/wrap/inline/fragment.rs |
ContinuationMode in src/wrap/paragraph/pending.rs selects normal joining,
opener-at-EOL tight joining, or original-line verbatim flushing for
PendingPrefix. The private code_span_trim module provides
trim_code_span_edge_spaces for metadata-guided trimming of synthetic
code-span boundary spaces.
SpanKind in src/wrap/inline/span_helpers.rs records how a grouped token
span behaves while determine_token_span walks the stream: General for
ordinary prose, Code and Link for atomic inline spans, and FootnoteRef
when a footnote marker has been promoted or grouped with preceding punctuation.
- Public API stability.
mdtablefix::wrap::wrap_text,Token, andtokenize_markdownmust not change their signatures or observable behaviour. - Shared fence tracking.
tokenize_markdown()insrc/wrap/tokenize/mod.rsuses the sameFenceTrackerimplementation aswrap_textandsrc/wrap/fence.rs, rather than a local boolean, to track whether the tokenizer is inside a fenced code block. Once a structural opening fence is observed, the tokenizer emits the opener, every interior line, and the matching closer asToken::Fence, then resumes inline tokenization for following prose. Every line inside an open fence preserves its byte content verbatim, so post-wrap transforms such as--ellipsis,--renumber,--breaks, and--fencescannot mutate fenced code block bodies. This behaviour was introduced for issue#329in PR#343, including nested literal fences whose marker run is shorter than the active outer fence. - Atomic fragments. The inline fitter never introduces a new break inside
inline code spans, Markdown links, or GFM footnote references. These
fragments move as a unit when they would overflow the target width. A
conforming source boundary already inside an overlong cross-line code span
may instead be retained; Markdown renders that soft break as a space. Opening
punctuation that immediately precedes an inline code span or link is grouped
with that span during token grouping so the opener is not left on the
previous line. Trailing punctuation after those spans follows the same
grouping rules. GFM footnote references that immediately follow inline code
or link spans without intervening whitespace are coupled to the preceding
punctuation cluster, so the marker is not wrapped onto the next line alone.
Inflectional affixes (
s,'s,ed,ing) and hyphenated compounds that immediately follow a closed backtick fence are absorbed into the code token byscan_code_suffix_endinsrc/wrap/tokenize/scanning.rs; the combined token is recognized as atomic byhas_inline_code_structureinsrc/wrap/inline/fragment.rs, so wrapping treats the full string as one unit. Leading-hyphen compounds — a token that ends with a hyphen and contains at least one alphabetic character (for examplepre-,LLM-,(API-) — are coupled forward to the next inline code span during span grouping by theends_with_hyphen_prefixpredicate insrc/wrap/inline/predicates.rs, applied indetermine_token_spaninsrc/wrap/inline.rs. The coupling mirrors the existing opening-punctuation pattern, so compounds such aspre-`LLMPort`and(API-`Foo`)remain atomic during wrapping. Internal hyphen chains (e.g.state-of-the-art-) are accepted by design; bare dash runs such as-or---are rejected. Unicode alphabetic characters (e.g.pré-,字-) are intentionally supported. - Hard breaks. Trailing two-space hard breaks must survive on the emitted line where they occur.
- Verbatim blocks. Fenced code blocks must pass through unchanged, along
with the other non-paragraph block kinds detected by
classify_block. - Prefix width. The visual width of every prefix string is measured with
UnicodeWidthStr::widthbefore the available text width is computed, so non-ASCII prefix characters (e.g.「in CJK blockquotes) are accounted for correctly. - Cross-line code spans. When a prefixed line contains an unclosed inline
code span,
PendingPrefixbuffers the continuation until the span closes. The wrapper does not introduce a new break inside the span. If the joined span exceeds the width, it may preserve conforming authored boundaries inside the span, including the correct repeated or indented continuation prefix, while prose outside the span remains eligible for ordinary greedy reflow. - Closing fence detection. Backslash escape checks apply only while
detecting opening backtick fences in ordinary Markdown text. Once a code span
is open, backslashes in the span content are literal bytes and must not make
a matching closing fence invisible. All tokenizer entry points that close
code spans use
position_after_closeso they also reject candidate closers embedded in a longer backtick run. - Width-aware inline-code carries.
merge_whitespace_only_linesreceives the active wrap width fromwrap_preserving_code. Before carrying a previous inline-code tail across a single-space wrap artefact, it must compute the projected destination line width and skip the carry when that projection would exceed the configured width. WRAP_COLSpublic constant.mdtablefix::process::WRAP_COLSis exported aspubso that integration tests can reference the production wrap width instead of hard-coding80. When writing tests that depend on the column boundary (for example, wrap-boundary edge-case tests), import and useWRAP_COLSas the single source of truth. Do not duplicate the literal value80in test code.
Refer to docs/adrs/0002-textwrap-wrapping-engine.md for the rationale behind
replacing LineBuffer with textwrap.
tracing = "0.1" is the runtime observability dependency, used by both the
library and executables. tracing-test = "0.2" is a test-only dev-dependency;
use it only in tests (e.g. #[traced_test]). The crate does not install a
global subscriber or metrics recorder. Executables and test harnesses that want
log output must install their own subscriber (e.g.
tracing_subscriber::fmt::init() in main).
Use debug! for high-value classification outcomes: fragment kind, parsed
token length, span promotion result, parsed blockquote prefix, and fence-state
transitions. Use trace! for branch-level checks: predicate matched, prefix
mismatch, unterminated bracket, rejected blockquote prefix, and incompatible
fence marker. Never emit at info! or above from library code.
Use the stable structured field names token_length, kind, start, end,
width, reason, is_image, row_index, cell_count, and error_category.
Blockquote and fence events additionally use line_len, prefix_len, depth,
inner_len, open_depth, marker_len, open_marker_len, and transition.
These events are content-free: never include raw Markdown, blockquote prefixes,
fence info strings, or other document content. Executables remain responsible
for installing subscribers.
Blockquote parsing emits blockquote prefix parsed or
blockquote prefix rejected. Fence tracking emits fence state changed with
the open, matching_close, or implicit_close transition, and
fence marker did not change state with the unchanged transition. The
corresponding reason values are no_blockquote_prefix,
blockquote_depth_decreased, and incompatible_active_opener.
Table: Structured field names emitted by tracing instrumentation.
| Field | Type | Used in | Meaning |
|---|---|---|---|
token_length |
usize |
fragment, link, footnote events | Character count of the text that was classified or parsed |
kind |
?FragmentKind |
fragment classified |
The computed fragment classification |
start |
usize |
span events | Byte offset where the span begins |
end |
usize |
span events | Byte offset where the span ends (exclusive) |
width |
usize |
span events | Display-column width of the span |
reason |
&str |
rejected, unchanged, or fence-state decisions | Stable diagnostic category for any decision |
is_image |
bool |
link or image parsed |
true when the link token is an image literal (![]()) |
row_index |
usize |
table-row events | Zero-based index of the parsed logical row |
cell_count |
usize |
table-row events | Number of cells in the parsed logical row |
error_category |
&str |
declined or discarded events | Stable category for a non-successful classification outcome |
line_len |
usize |
blockquote-prefix events | Byte length of the examined source line |
prefix_len |
usize |
blockquote-prefix events | Byte length of the recognized blockquote prefix |
depth |
usize |
blockquote and fence events | Current blockquote nesting depth |
inner_len |
usize |
blockquote-prefix events | Byte length after removing the blockquote prefix |
open_depth |
usize |
fence-state events | Blockquote depth of the active fence opener |
marker_len |
usize |
fence-state events | Length of the currently recognized fence marker |
open_marker_len |
usize |
fence-state events | Length of the active opening fence marker |
transition |
&str |
fence-state events | Stable fence-state transition category |
For example:
debug!(token_length = token.chars().count(), kind = ?kind, "fragment classified");Guard any expression that performs non-trivial work with
tracing::enabled!(Level::DEBUG) or tracing::enabled!(Level::TRACE) before
computing the value.
Tracing events must not include raw document content. Record bounded metadata such as indices, lengths, fragment kinds, and stable error categories instead. In particular, do not rely on downstream subscribers to redact link, footnote, table-row, or token text.
Functions decorated with #[tracing::instrument] are listed below with their
level and notable fields. Update this list when adding new instrumented entry
points.
Table: Instrumented functions and their logging levels and fields.
| Function | Level | Fields |
|---|---|---|
looks_like_footnote_ref |
trace | skip(token), return value (out) |
ends_with_footnote_ref |
trace | skip(token), return value (out) |
ends_with_hyphen_prefix |
trace | skip(token), return value (out) |
is_month_name |
trace | skip(token), return value (out) |
is_ordinal_day |
trace | skip(token), return value (out) |
is_numeric_day |
trace | skip(token), return value (out) |
is_year |
trace | skip(token), return value (out) |
try_match_date_sequence |
trace, debug | start (in), skip(tokens), return value (out); matched date pattern |
date_token_span |
trace | start (in), skip(tokens), return value (out); over-width date fallback remains behaviour-only |
parse_link_or_image |
debug | idx (in), skip(text); token_length and is_image events |
find_footnote_end |
trace | idx (in), skip(text), return value (out) |
parse_rows |
trace, debug | skip(trimmed); row_index, cell_count, and error_category events |
The stable structured fields above are pinned by insta snapshots so that
accidental changes to a tracing event's level, target, message, or field set
are caught in review. These tests live next to the instrumented code:
src/wrap/inline/fragment_tracing_snapshots.rs–fragment classified.src/wrap/inline/span_helper_tracing_tests.rs– date-span events.src/wrap/tokenize/parsing_tracing_snapshots.rs– link, image, and footnote events.
Each is wired into its owning module as a #[cfg(test)] #[path = "…"]
submodule so the snapshot test sits beside the code it pins while keeping the
production module within the 400-line limit. The .snap fixtures live under the
neighbouring snapshots/ directory.
A test captures events with tracing-test's #[traced_test], then normalizes
the captured lines through the shared
crate::wrap::tracing_snapshot_support::normalise_event_lines helper before
asserting the snapshot.
tracing-test prefixes every captured line with a volatile timestamp and span
context, which would make raw snapshots non-deterministic.
normalise_event_lines(lines, message) retains only the lines containing
message, strips the volatile prefix up to the event level (TRACE/DEBUG),
trims trailing whitespace, and joins the survivors. The level, target, message,
and structured fields are preserved verbatim, so the snapshot still fails if any
of those change.
Re-use policy for this helper:
- Ownership. Owned by the wrap module (
src/wrap/tracing_snapshot_support.rs) and gated behind#[cfg(test)]; it ispub(crate)test-support code, not part of any public or runtime API. - Permitted call-sites. Only tracing-event snapshot tests. Call it from
inside a
#[traced_test]function through the injectedlogs_assertclosure, copying the normalized result into an owned buffer before asserting the snapshot after the closure returns (see the modules listed above for the canonical shape). - Composition. Do not layer additional normalization on top; if a new event needs different masking, extend the helper (and this section) rather than post-processing its output at the call-site, so every snapshot shares one normalization contract.
The fences module in src/fences.rs is responsible for
normalizing fenced code blocks before later Markdown transforms run. It exposes
two public functions that are called in sequence:
compress_fences(&[String]) -> Vec<String>conditionally compresses fence delimiters of three or more backticks or tildes to exactly three backticks when doing so preserves the structural interpretation of the inner content. If compression would make inner fence-like content become structural, the original outer delimiters are preserved.attach_orphan_specifiers(&[String]) -> Vec<String>reattaches language specifier lines that appear on a separate line before an unlabelled opening fence.
Together, these helpers make the rest of the processing pipeline deal with a single normalized fence, and avoid carrying separate logic for detached specifier lines.
Strategy is the canonical private choice for fence-marker rewriting. Its
variants have the following effects:
Compressrewrites the marker to exactly three backticks while preserving indentation and the language specifier.Preserveretains the original marker character and run length when an interior fence would otherwise become structural.
All fence-marker rewriting must dispatch on Strategy through rewrite_marker.
flush_matched_block selects the matched-block strategy, while
rewrite_fence_line only dispatches it and falls back to the original line.
The earlier implementation used two slice-and-index helpers,
orphan_specifier_target and orphan_specifier_target_without_language, to
search forward from the current position. That approach worked, but it split
the lookahead rules across multiple helpers and kept the main loop tied to
manual index management.
The current implementation converts slice traversal into a Peekable iterator
and centralizes the forward scan in one private helper, attach_to_next_fence.
attach_orphan_specifiers now acts as the coordinator: it identifies a
candidate orphan specifier, delegates lookahead to the helper, and otherwise
just pushes unchanged lines into the output.
attach_to_next_fence receives a Peekable iterator positioned immediately
after the current orphan specifier line.
It follows these rules:
- Peek at the next line. If it is blank, consume it, buffer it, and continue scanning.
- If the next non-blank line is a fence whose language is absent, meaning the
language is empty or the case-insensitive string
null, consume that fence, rewrite it with the normalized specifier and selected indentation, push the rewritten fence into the output buffer, and drop the buffered blank lines. This preserves the historical skip-blank-lines semantics, so no intervening blank lines appear in the output when attachment succeeds. - If the next non-blank line is not an attachable fence, stop scanning, push the original specifier line to the output, then extend the output with the buffered blank lines verbatim.
This structure keeps the one non-trivial lookahead path local to the helper instead of spreading it between the main loop and several index-based search utilities.
attach_specifier_to_fence controls which indentation is retained on the
rewritten opening fence.
- The fence's own indentation is preferred by default.
- If the fence has no indentation, the specifier's indentation is used.
- If
spec_indent.starts_with(fence_indent), the implementation treats the specifier's indentation as extending, or matching, the fence's indentation and uses it. Equality is allowed, sostarts_withcovers both exact matches and deeper indentations.
This rule keeps existing fenced block indentation stable while still handling the common case where the detached specifier line carried the indentation that should apply to the fence.
The CLI matrix harness in tests/cli_matrix.rs checks
that important option combinations keep working through the real mdtablefix
binary. It uses assert_cmd to run the command and insta to snapshot a
labelled envelope containing the case identifier, execution mode, arguments,
exit status, stdout, stderr, and rewritten file content where relevant.
The base catalogue lives in tests/cli_matrix/support.rs. It covers the seven non-wrap transform flags:
--renumber--breaks--ellipsis--fences--footnotes--code-emphasis--headings
The harness expands every base row into both --wrap and no---wrap variants.
It then runs each logical variant twice: once as file-to-stdout formatting and
once with --in-place against an equivalent temporary file. The snapshot test
also asserts that stdout output and the --in-place rewritten file are
identical for the same logical case.
Matrix input fixtures live under tests/data/cli-matrix/ and must use the
.dat extension. Do not use .md or .txt for these fixtures because
make fmt runs Markdown formatting and must not rewrite matrix inputs. The
harness has a self-test that rejects non-.dat fixtures.
make typecheck runs cargo check --all-targets --all-features to verify
type-correctness without running tests. Use it for rapid feedback during
development before moving on to the full lint and test gates.
Before changing snapshots, run the harness self-tests:
cargo test --test cli_matrix matrix_case_ids_are_unique
cargo test --test cli_matrix matrix_cases_expand_to_stdout_and_in_place
cargo test --test cli_matrix matrix_cases_expand_to_wrapped_and_unwrapped
cargo test --test cli_matrix matrix_cases_cover_all_transform_pairsCreate or update snapshots only when the behaviour change is intentional:
INSTA_UPDATE=always cargo test --test cli_matrix cli_matrix_snapshots
cargo test --test cli_matrixReview the generated tests/snapshots/cli_matrix__*.snap files before
committing. Snapshot churn across many cases usually means the fixture is too
broad or a shared transform changed behaviour; inspect the labelled case, mode,
and arguments before accepting the new output.
Internal state carriers centralize the buffered state used by the conversion pipeline. Each owns one slice of pipeline behaviour, so the surrounding functions remain focused on traversal. Keep new parser and wrapping state machines explicit unless they meet the adoption threshold in ADR 0004. That decision records the crate research and the local pattern maintainers should follow when changing stateful helpers.
Keep an explicit Rust struct, enum, and event-shaped helper unless every item below is true. If every item is true, build a small comparison spike before proposing a dependency:
- the transition graph is larger or more important than its Markdown parsing predicates;
- explicit events, rather than source-line predicates, drive transitions;
- lifecycle hooks, recoverable transition errors, or introspection would otherwise be duplicated across modules; and
- the spike demonstrates that generated or framework-driven code is shorter and more legible than equivalent explicit Rust.
Evaluate statig first for a dynamic event-driven machine and smlang second
for a compact transition table. Record graph size, event mapping, lifecycle and
error semantics, observability, generated-code legibility, and dependency
adoption risk in an ADR update before adding a crate.
ParagraphState owns buffered prose, shared indentation, remembered
continuation indentation, and an optional PendingPrefix. PendingPrefix
carries the prefix, source lines, synthetic join offsets, available width, and
open-fence metadata while an inline-code span crosses source lines.
ContinuationMode selects normalized, tight, or verbatim emission, while
TailReflow decides whether prose after a resolved span may remain buffered.
The private wrap::paragraph::spanning_code helper preserves a conforming
authored boundary when joining its overlong span would exceed the width; it
restores stored indentation and returns prose outside that span to normal
wrapping. Emit stable trace! fields at verbatim-preservation,
prefix-mismatch, and tail-reflow transitions so maintainers can inspect why
output changed.
HtmlTableState buffers the lines belonging to an HTML <table>…</table>
block and tracks the current nesting depth. in_html() returns true whenever
the buffer is non-empty, so the caller knows a table is still being accumulated.
push_html_line appends the supplied line, increments depth once for every
<table> start tag found on the trimmed line, and decrements it once for every
</table> end tag on the same trimmed line. When depth returns to zero, the
buffered lines are converted by table_lines_to_markdown and the buffer is
cleared. flush_raw exists for the fenced-block escape path: it emits the
buffered lines verbatim without conversion, so raw HTML inside a fenced code
block is preserved unchanged.
DefinitionScanState accumulates the footnote-definition rewrite plan during a
single scan over the input. It borrows the shared (original → new) mapping
and the next_number counter so renumbering decisions stay consistent with
explicit reference rewrites. Explicit [^n]: headers are appended to
definitions as soon as they are encountered, producing a DefinitionLine per
header in scan order. Ordered-list items that look like candidate footnote
definitions are buffered as NumericCandidate entries during the scan and
finalized at the end via finalize_numeric_candidates, which drains the buffer
in reverse, so the assigned numbers reflect bottom-up ordering rather than the
order in which the candidates were discovered.
ListState maintains an indent stack and a per-indent counter map for
ordered-list renumbering. next_number(indent) first prunes indent levels
deeper than indent (their counters disappear so a future deeper level
restarts at 1), pushes indent onto the stack if it is new, and returns the
next sequential number for that level — incrementing the counter, so the next
call at the same indent receives the following integer. reset() clears both
the stack and the counter map; the renumbering pass invokes it when a heading
or thematic break is encountered, so the next list starts numbering from 1
again.
Integration-test helpers are organized under tests/support/:
Table: Integration-test support modules and their purposes.
| Module | Purpose |
|---|---|
cli_args.rs |
run_cli_with_args — invokes the binary with argument-only tests |
cli_stdin.rs |
run_cli_with_stdin — invokes the binary feeding stdin |
fixtures.rs |
Shared rstest fixtures (e.g. broken_table) |
wrap_assertions.rs |
Higher-level assertions for wrapping output |
Each integration-test file declares the modules it needs via explicit
#[path = "support/…"] attributes, keeping inter-test coupling minimal.
tests/common/mod.rs exports two #[macro_export] macros available to all
integration-test crates:
Table: Macros for building Vec<String> from literals and file lines.
| Macro | Purpose |
|---|---|
lines_vec![…] |
Builds a Vec<String> from string-like values. |
include_lines!("path") |
Builds a Vec<String> from file lines. |
lines_vec![…] reduces boilerplate when constructing fixture inputs.
include_lines!("path") uses include_str! at compile time and returns one
String per line of the referenced file.
Both macros are exported rather than kept private because Rust's macro scoping
rules require #[macro_export] for macros to be visible across
integration-test binary crates. The #[expect(unused_macros)] suppressions
that previously guarded them were replaced by the export attribute when it
became clear that multiple test binaries depend on them.
The test-macros workspace crate provides the allow_fixture_expansion_lints
proc-macro attribute. It suppresses the unused_braces lint that rstest
fixture expansion triggers when fn_single_line = true is set in
rustfmt.toml.
The macro emits #[allow(unused_braces, …)] rather than #[expect(…)] because
the Rust proc-macro API delivers a pre-parsed token stream; the emitted lint
attribute applies to code that the compiler has not yet expanded, making
#[expect] semantically unusable at that site. This is a known consequence of
the rstest fixture expansion and is not a lint-integrity violation.
Apply it to any fixture function whose single-expression body triggers the lint:
#[test_macros::allow_fixture_expansion_lints]
#[rstest::fixture]
pub fn broken_table() -> Vec<String> { … }format_breaks in src/breaks.rs returns
Vec<Cow<'_, str>> so unchanged lines can be forwarded without allocating.
Lines that do not match a thematic break are emitted as Cow::Borrowed slices
into the input &[String]. Synthesized thematic-break lines are also emitted as
Cow::Borrowed, pointing to the shared LazyLock<String> static
THEMATIC_BREAK_LINE. Callers that need owned String values must call
.into_owned() on each item.