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#[derive(Debug, Clone)]
22pub enum FormatError {
23 Parse(ParseError),
25 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#[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#[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#[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
84pub fn format_source(source: &str, opts: &FmtOptions) -> Result<String, FormatError> {
90 Ok(format_document(&Document::parse(source)?, opts))
91}
92
93pub fn format_validated(doc: &Document, opts: &FmtOptions) -> Result<String, FormatError> {
100 format_validated_with_report(doc, opts).map(|(formatted, _report)| formatted)
101}
102
103pub 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#[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#[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}