Skip to main content

mdwright_format/
lib.rs

1#![forbid(unsafe_code)]
2
3mod format;
4mod incremental;
5mod options;
6
7use std::fmt;
8use std::ops::Range;
9
10pub use format::semantic::{first_divergence, semantically_equivalent};
11pub use incremental::CheckpointTable;
12pub use options::{
13    EndOfLine, FmtOptions, HeadingAttrsStyle, ItalicStyle, LinkDefStyle, ListContinuationIndent, ListMarkerStyle,
14    MathOptions, MathRender, OrderedListStyle, Placement, StrongStyle, TableStyle, ThematicStyle, TrailingNewline,
15    Wrap, WrapStrategy,
16};
17
18use mdwright_document::{Document, ParseError};
19
20/// Errors returned by [`format_validated`].
21#[derive(Debug, Clone)]
22pub enum FormatError {
23    /// Source or formatted output could not be parsed safely.
24    Parse(ParseError),
25    /// The formatter changed the document's meaning.
26    SemanticDivergence {
27        source: String,
28        formatted: String,
29        diff_summary: String,
30    },
31}
32
33impl fmt::Display for FormatError {
34    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35        match self {
36            Self::Parse(err) => write!(f, "{err}"),
37            Self::SemanticDivergence { diff_summary, .. } => {
38                write!(f, "formatter changed the document's meaning: {diff_summary}")
39            }
40        }
41    }
42}
43
44impl std::error::Error for FormatError {}
45
46impl From<ParseError> for FormatError {
47    fn from(value: ParseError) -> Self {
48        Self::Parse(value)
49    }
50}
51
52/// Aggregate formatter rewrite counts.
53///
54/// The report is intentionally coarse: it exposes whether rewrite
55/// transactions are being attempted, committed, or rejected without
56/// exposing candidates, owners, snapshots, or parser internals.
57#[derive(Clone, Debug, Default, PartialEq, Eq)]
58pub struct FormatReport {
59    pub rewrite_candidates: usize,
60    pub rewrite_committed: usize,
61    pub rewrite_committed_wrap: usize,
62    pub rewrite_committed_style: usize,
63    pub rewrite_rejected_overlap: usize,
64    pub rewrite_rejected_verification: usize,
65    pub rewrite_rejected_convergence: usize,
66    pub rewrite_skipped_wrap: usize,
67}
68
69/// Format a parsed document.
70#[must_use]
71#[tracing::instrument(level = "info", name = "format_document", skip_all, fields(out_len = tracing::field::Empty))]
72pub fn format_document(doc: &Document, opts: &FmtOptions) -> String {
73    let out = format::document::format_document(doc, opts);
74    tracing::Span::current().record("out_len", out.len());
75    out
76}
77
78/// Format a parsed document and return aggregate rewrite metrics.
79#[must_use]
80pub fn format_document_with_report(doc: &Document, opts: &FmtOptions) -> (String, FormatReport) {
81    format::document::format_document_with_report(doc, opts)
82}
83
84/// Parse and format Markdown source with default parse options.
85///
86/// # Errors
87///
88/// Returns [`FormatError::Parse`] if source parsing fails.
89pub fn format_source(source: &str, opts: &FmtOptions) -> Result<String, FormatError> {
90    Ok(format_document(&Document::parse(source)?, opts))
91}
92
93/// Format and verify that a second pass is semantically stable.
94///
95/// # Errors
96///
97/// Returns an error if formatting the output a second time produces a
98/// different canonical event stream.
99pub fn format_validated(doc: &Document, opts: &FmtOptions) -> Result<String, FormatError> {
100    format_validated_with_report(doc, opts).map(|(formatted, _report)| formatted)
101}
102
103/// Format, return aggregate metrics, and verify that a second pass is
104/// semantically stable.
105///
106/// # Errors
107///
108/// Returns an error if formatting the output a second time produces a
109/// different canonical event stream.
110pub fn format_validated_with_report(doc: &Document, opts: &FmtOptions) -> Result<(String, FormatReport), FormatError> {
111    let (formatted, report) = format_document_with_report(doc, opts);
112    let formatted_doc = Document::parse_with_options(&formatted, doc.parse_options())?;
113    let twice = format_document(&formatted_doc, opts);
114    match format::semantic::first_divergence_with_options(&formatted, &twice, doc.parse_options())? {
115        None => Ok((formatted, report)),
116        Some(diff_summary) => Err(FormatError::SemanticDivergence {
117            source: formatted.clone(),
118            formatted: twice,
119            diff_summary,
120        }),
121    }
122}
123
124/// Format the smallest set of whole top-level blocks that covers
125/// `range` in `source`.
126#[must_use]
127pub fn format_range(doc: &Document, opts: &FmtOptions, range: Range<usize>) -> String {
128    let table = CheckpointTable::from_document(doc);
129    format_range_with_checkpoints(doc, opts, &table, range)
130}
131
132/// Range-format using a pre-built [`CheckpointTable`].
133#[must_use]
134pub fn format_range_with_checkpoints(
135    doc: &Document,
136    opts: &FmtOptions,
137    table: &CheckpointTable,
138    range: Range<usize>,
139) -> String {
140    let req_lo = u32::try_from(range.start).unwrap_or(0);
141    let req_hi = u32::try_from(range.end).unwrap_or(u32::MAX);
142    let snapped = table.snap_to_block_boundaries(req_lo..req_hi);
143    let lo = snapped.start as usize;
144    let hi = snapped.end as usize;
145    let source = doc.source();
146    let slice = source.get(lo..hi).unwrap_or("");
147    match Document::parse_with_options(slice, doc.parse_options()) {
148        Ok(slice_doc) => format::document::format_document(&slice_doc, opts),
149        Err(err) => {
150            tracing::warn!(
151                target: "mdwright::format",
152                error = %err,
153                "range-format slice parse failed; leaving slice bytes unchanged",
154            );
155            format::document::format_unparsed_source(slice, opts)
156        }
157    }
158}
159
160#[cfg(test)]
161#[allow(clippy::expect_used)]
162mod tests {
163    use super::*;
164    use mdwright_document::{ExtensionOptions, ParseOptions};
165
166    #[test]
167    fn format_document_uses_document_parse_options() {
168        let source = "# Heading {.class #id}\n";
169        let opts = FmtOptions::default().with_heading_attrs(HeadingAttrsStyle::Canonicalise);
170
171        let enabled = Document::parse(source).expect("fixture parses");
172        assert_eq!(format_document(&enabled, &opts), "# Heading {#id .class}\n");
173
174        let parse_options = ParseOptions::default().with_extensions(ExtensionOptions {
175            heading_attribute_lists: false,
176            ..ExtensionOptions::default()
177        });
178        let disabled = Document::parse_with_options(source, parse_options).expect("fixture parses");
179        assert_eq!(format_document(&disabled, &opts), source);
180    }
181
182    #[test]
183    fn mdformat_profile_reports_no_candidates_when_no_sites_match() {
184        let doc = Document::parse("plain paragraph\n").expect("fixture parses");
185        let (formatted, report) = format_document_with_report(&doc, &FmtOptions::mdformat());
186        assert_eq!(formatted, doc.source());
187        assert_eq!(report, FormatReport::default());
188    }
189
190    #[test]
191    fn default_table_normal_form_keeps_table_free_fast_path() {
192        let doc = Document::parse("plain paragraph\n").expect("fixture parses");
193        let (formatted, report) = format_document_with_report(&doc, &FmtOptions::default());
194        assert_eq!(formatted, doc.source());
195        assert_eq!(report, FormatReport::default());
196    }
197}