Library API notes

Version 0.6.1 Updated Sep 29, 2026

Stream formatting

process_stream reflows tables and wraps paragraphs using the default options; it does not enable ordered-list renumbering implicitly. Library callers that want renumbering must use process_stream_opts with Options::renumber set to true. The command-line --renumber flag opts into the same option.

Atomic in-place rewrites

rewrite(path) and rewrite_no_wrap(path) give library callers the same guarantee as --in-place: the replacement is written to a temporary file beside the target, flushed, and renamed over it, with the original file mode preserved. The temporary file receives the target's permissions before the rename, so a read-only target is replaced by a read-only file rather than by a writable one. On Windows, where the destination's FILE_ATTRIBUTE_READONLY blocks the rename outright, that attribute is cleared immediately before the rename and put back if the swap does not complete. Symbolic links are declined, as described in In-place editing.

Callers that already hold a cap_std::fs_utf8::Dir capability can use mdtablefix::io::replace_file(directory, path, contents) instead. It performs the same temporary-file-and-rename sequence relative to the supplied directory, so no ambient filesystem access is needed. A caller that has just read the target can condition the replacement on that reading with replace_file_if_unchanged(directory, path, expected, contents), which writes as replace_file does, but reads the target back immediately before the rename and replaces it only while the target still holds expected. An Ok(false) return means the target was left exactly as it was and the temporary file was removed, and a target that cannot be read back at all is an error. The conditional entry point is not a true compare-and-swap, but every window before the rename is closed. The CLI and the two path helpers all call the conditional form, so the sequence has one implementation.

Replacement metrics

The library emits six metrics for its replacement path: five counters and one histogram. It installs no recorder or subscriber of its own; a host application installs one to collect these metrics, and a host that installs none sees no behavioural change.

  • mdtablefix_io_replace_total — replacements attempted, labelled by outcome (success, unchanged, or failure).
  • mdtablefix_io_replace_duration_seconds — histogram of replacement durations in seconds, labelled by outcome.
  • mdtablefix_io_temporary_name_collisions_total — candidate temporary names rejected because they were already taken.
  • mdtablefix_io_temporary_name_exhausted_total — replacements abandoned because every candidate temporary name was taken.
  • mdtablefix_io_temporary_cleanup_failures_total — temporary files a failed replacement could not remove.
  • mdtablefix_io_symlink_declined_total — symbolic-link targets declined with InvalidInput rather than replaced.

Cleanup is best effort, so the caller still sees the failure that prompted it; the cleanup counter is the only signal that a stale temporary file was left beside the target. No temporary file is created for a declined link.

Metric names are stable, and labels are bounded: outcome is the only label key, carried only by the two replacement metrics above. No path, file name, or error text is ever used as a label, so a recorder's cardinality stays bounded.

`format_breaks` return type

format_breaks returns Vec<Cow<'_, str>> rather than Vec<String>. Lines that are not thematic breaks are returned as Cow::Borrowed slices into the input, avoiding heap allocations for unchanged content. Synthesized thematic-break lines without a blockquote prefix are also Cow::Borrowed, borrowing from a shared static buffer. Quoted thematic breaks retain their quote prefix and are returned as Cow::Owned.

Callers that need owned String values must call .into_owned():

use mdtablefix::format_breaks;

let lines = vec!["some text".to_string(), "---".to_string()];
let owned: Vec<String> = format_breaks(&lines)
    .into_iter()
    .map(|c| c.into_owned())
    .collect();

Footnote stages

The crate root re-exports convert_footnotes, the whole-document convenience form. It runs three public stages in order: footnotes::convert_inline_footnotes, then footnotes::renumber_footnote_labels, then footnotes::convert_footnote_definitions. Callers that interleave passes of their own run the first two ahead of any pass that lays text out, and the third once the block structure and the layout have settled. See footnote conversion for why the order is forced.

Line-ending helpers

LineEnding is the closed set of terminators the formatter emits. LineEnding::Lf is \n and LineEnding::Crlf is \r\n. The const fn LineEnding::as_str returns the characters written between lines.

detect_line_ending(text) -> LineEnding selects the style holding the strict majority of the text's line endings. CRLF pairs are counted first and subtracted from the line-feed count to obtain the lone line feeds; CRLF wins only when it strictly outnumbers lone line feeds. An exact tie, and a non-empty input with no line endings at all, select LineEnding::Lf. Only CRLF and lone LF are recognized: a lone carriage return is content, matching the str::lines split.

count_line_endings(text) -> LineEndingCounts returns the selection together with the counts that decided it. LineEndingCounts::ending is the selected style, crlf_count counts CRLF pairs, and lone_lf_count counts lone line feeds, so a caller that reports or acts on the vote does not restate the counting rule.

The boundaries that act on the decision (rewrite, rewrite_no_wrap, and the CLI's file and standard-input boundaries) each emit one debug event, selected the majority line ending, with the fields operation, path, crlf_count, lone_lf_count, and selected_ending. The event is visible to anyone who enables debug logging.

serialize_lines(lines, ending) -> String joins the processed lines with the selected terminator and appends one further terminator, so a non-empty result always ends with a line ending; an empty slice yields an empty string.

See Line endings for the user-facing behaviour.

use mdtablefix::{
    LineEnding, count_line_endings, detect_line_ending, serialize_lines,
};

let counts = count_line_endings("alpha\r\nbeta\r\ngamma\n");
assert_eq!(counts.ending, LineEnding::Crlf);
assert_eq!(counts.crlf_count, 2);
assert_eq!(counts.lone_lf_count, 1);

let ending = detect_line_ending("alpha\r\nbeta\r\n");
assert_eq!(ending, LineEnding::Crlf);

let lines = vec!["| A |".to_string(), "| 1 |".to_string()];
assert_eq!(serialize_lines(&lines, ending), "| A |\r\n| 1 |\r\n");
assert!(serialize_lines(&[], ending).is_empty());

Reporting: line deltas and unified diffs

mdtablefix::report is the pure half of --check and --diff. Nothing in it opens a path, chooses an output stream, or defines an error type: it counts what changed and renders it into a writer the caller supplies, so a library caller produces exactly what the command line reports without restating the counting rule.

LineDelta::between(original, formatted) counts the lines that turning original into formatted would insert and delete. A modified line counts as one insertion and one deletion, matching git diff --numstat, and the counts are read back with insertions(), deletions(), and has_changes(). The count comes from the same tokenizer that renders the diff, so the two cannot disagree about where a line ends. Callers compare bytes first: equal texts need no diff work, and between is not written to be called on them.

render_report_line(display_path, delta) renders --check's one-line form, for example docs/a.md +12 -8. Consumers parse it by taking the final two whitespace-separated fields as the counts and everything before them as the path, as Reading a report line describes. render_summary(changed, unchanged, errored) renders the trailing summary, for example 2 files would be reformatted, 1 file left unchanged.; it belongs on standard error, so standard output stays a machine contract.

write_unified_diff(out, display_path, original, formatted, options) streams --diff's output to any io::Write. Both headers name display_path with directory separators normalized to / and carry no timestamps, so the same input renders the same bytes on every platform. The \ No newline at end of file marker is preserved, and can only appear on the - side because the formatter always terminates the lines it emits. Output is never colourized. DiffOptions fixes the context radius and the line count above which rendering switches from Myers to Patience, so diffing a very large file stays bounded without a wall-clock cut-off.

FileReport is one file's analysis as a value — its display path, whether the formatter would change it, and its LineDelta — so a caller can render or aggregate it rather than parsing rendered text back apart.

use camino::Utf8Path;
use mdtablefix::report::{
    DiffOptions, LineDelta, render_report_line, write_unified_diff,
};

let original = "|A|B|\n";
let formatted = "| A | B |\n";

let delta = LineDelta::between(original, formatted);
assert_eq!((delta.insertions(), delta.deletions()), (1, 1));
assert!(delta.has_changes());
assert_eq!(
    render_report_line(Utf8Path::new("ragged.md"), delta),
    "ragged.md +1 -1"
);

let options = DiffOptions {
    context_radius: 3,
    patience_threshold: 1000,
};
let mut diff = Vec::new();
write_unified_diff(
    &mut diff,
    Utf8Path::new("ragged.md"),
    original,
    formatted,
    options,
)
.expect("writing to a Vec cannot fail");
assert_eq!(
    String::from_utf8(diff).expect("the diff is UTF-8"),
    "--- ragged.md\n+++ ragged.md\n@@ -1 +1 @@\n-|A|B|\n+| A | B |\n"
);

Byte-order marks and the document boundary

mdtablefix::io::SourceDocument binds a document's boundary concerns to the text they were read from. A byte-order mark and the line-ending style are boundary concerns rather than content concerns: both are split off before formatting and restored afterwards, so every content transform sees the same lines however the file was authored, and a Windows-authored file is not silently rewritten to line feeds.

SourceDocument::parse(content) strips one leading U+FEFF and counts the line endings of what remains. Splitting the mark off matters beyond fidelity: left attached to the first line it defeats every content transform, which would make --check report a ragged file as clean. Counting over the body rather than over the whole input is deliberate — the mark is not a line ending, and on a document that is only a mark it would otherwise be the sole reason a majority existed.

body() borrows that text: the mark gone, the endings intact. It is what a content transform is given. counts() returns the LineEndingCounts that decided the style, and ending() returns the selection alone, so a caller that reports the vote and a caller that only needs the terminator read the same answer.

render(lines) writes lines back with the mark and the selected terminator. It is a method rather than a free function so a caller cannot render one document's lines with another document's style. An empty slice renders the mark alone, or nothing when the document had none, so a document that formats to no lines stays byte-identical to its input: --check remains a fixed point and --in-place cannot destroy the mark.

use mdtablefix::io::SourceDocument;

let document = SourceDocument::parse("\u{FEFF}|A|B|\r\n|1|2|\r\n");
assert_eq!(document.body(), "|A|B|\r\n|1|2|\r\n");
assert_eq!(document.ending().as_str(), "\r\n");
assert_eq!(document.counts().crlf_count, 2);

let formatted = ["| A   | B   |".to_string(), "| --- | --- |".to_string()];
assert_eq!(
    document.render(&formatted),
    "\u{FEFF}| A   | B   |\r\n| --- | --- |\r\n"
);

// A document that formats to no lines renders as nothing, not as a bare
// terminator, so a mark-only document survives unchanged.
assert!(SourceDocument::parse("").render(&[]).is_empty());