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 byoutcome(success,unchanged, orfailure).mdtablefix_io_replace_duration_seconds— histogram of replacement durations in seconds, labelled byoutcome.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 withInvalidInputrather 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());