Skip to main content

svugy_parse/markdown/
print.rs

1//! Print a model back as a Markdown diagram, the text form any imported model can be saved as.
2
3use std::collections::{BTreeMap, HashMap};
4use std::fmt;
5
6use thiserror::Error;
7
8use svugy_model::description::{DescriptionTarget, TargetLink};
9use svugy_model::link::Link;
10use svugy_model::model::{Edge, EdgeId, Element, Model, PlacementHint};
11use svugy_model::path::ElementPath;
12use svugy_model::style::{ColorKey, NumberKey, NumberValue, StyleValue};
13
14use super::ast::Value;
15use super::parse::ItemLabel;
16use super::property::LineStyle;
17use super::skeleton::{DESCRIPTION_INFO, Skeleton};
18
19/// Name the style key an element's `color` lowers to.
20const FILL: &str = ColorKey::FILL.name();
21/// Name the style key an element's `stroke` and an edge's `color` lower to.
22const STROKE: &str = ColorKey::STROKE.name();
23/// Name the style key `line=dashed` and `line=dotted` lower to.
24const STROKE_DASH: &str = NumberKey::STROKE_DASH.name();
25/// List the element style keys a language property says: `color`, `stroke` and `line`.
26const SAYABLE_ELEMENT_STYLE_KEYS: [&str; 3] = [FILL, STROKE, STROKE_DASH];
27/// List the edge style keys a language property says: `color` and `line`.
28const SAYABLE_EDGE_STYLE_KEYS: [&str; 2] = [STROKE, STROKE_DASH];
29/// List the characters that end a one-line list item.
30const LINE_BREAKS: [char; 2] = ['\n', '\r'];
31/// Spell the opening of an HTML comment, which the language drops wherever it appears.
32const COMMENT_OPENING: &str = "<!--";
33/// List the first characters CommonMark may read, at the start of a list item, as a heading, quote, list, rule, code fence, HTML block or link definition.
34const BLOCK_MARKERS: [char; 10] = ['#', '>', '-', '+', '*', '_', '`', '~', '<', '['];
35/// Count the backticks of the shortest code fence CommonMark reads.
36const MIN_FENCE_LENGTH: usize = 3;
37/// List the characters that turn leading digits into an ordered list marker.
38const ORDERED_LIST_DELIMITERS: [char; 2] = ['.', ')'];
39
40/// Report every part of a model Markdown cannot say, so one round fixes them all.
41#[derive(Clone, Debug, PartialEq, Error)]
42#[error(
43    "Markdown cannot say {} part(s) of this model:{}",
44    .unprintables.len(),
45    Self::bulleted(.unprintables)
46)]
47pub struct UnprintableModel {
48    pub unprintables: Vec<Unprintable>,
49}
50
51impl UnprintableModel {
52    /// Spell each unprintable part on a line of its own, opened by `- `.
53    fn bulleted(unprintables: &[Unprintable]) -> String {
54        let lines = unprintables
55            .iter()
56            .map(|unprintable| format!("\n- {unprintable}"));
57        lines.collect()
58    }
59}
60
61/// Name one part of a model Markdown cannot say, with the element, edge or legend it belongs to.
62#[derive(Clone, Debug, PartialEq, Error)]
63#[error("{owner}: {reason}")]
64pub struct Unprintable {
65    pub owner: UnprintableOwner,
66    pub reason: UnprintableReason,
67}
68
69/// Name the diagram, element, edge or legend an unprintable part belongs to.
70#[derive(Clone, Debug, PartialEq)]
71pub enum UnprintableOwner {
72    Diagram,
73    Element(ElementPath),
74    Edge(EdgeId),
75    Legend,
76}
77
78impl fmt::Display for UnprintableOwner {
79    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
80        match self {
81            Self::Diagram => formatter.write_str("the diagram"),
82            Self::Element(path) => write!(formatter, "element `{path}`"),
83            Self::Edge(id) => write!(formatter, "edge `{id}`"),
84            Self::Legend => formatter.write_str("the legend"),
85        }
86    }
87}
88
89/// Explain why Markdown cannot say one part of a model.
90#[derive(Clone, Debug, PartialEq, Error)]
91pub enum UnprintableReason {
92    #[error(
93        "path segment `{0}` is not an ID: ASCII letters, digits, `_` and single inner `-`, starting with a letter or `_`"
94    )]
95    InvalidId(String),
96    #[error("metadata name `{0}` is not an ID, so no `meta.NAME` key spells it")]
97    InvalidMetadataName(String),
98    #[error("its {field} {text:?} holds a line break, which no one-line item can")]
99    LineBreak { field: TextField, text: String },
100    #[error("its {field} {text:?} starts or ends with whitespace, which the language trims")]
101    PaddedText { field: TextField, text: String },
102    #[error("its {field} {text:?} holds `<!--`, which the language reads as a comment and drops")]
103    CommentOpening { field: TextField, text: String },
104    #[error(
105        "its description does not read back verbatim from a ```description block: Markdown reads a carriage return as a line break"
106    )]
107    UnreadableDescription,
108    #[error("its title ends with `#`, which Markdown reads as the heading's closing `#`s")]
109    TitleEndingWithHash,
110    #[error("it has an empty entry, which no list item can hold")]
111    EmptyEntry,
112    #[error(
113        "entry `{0}` opens like a Markdown heading, quote, list, rule, code fence, HTML block or link definition, so it would not read back as text"
114    )]
115    BlockOpeningEntry(String),
116    #[error("entry `{0}` starts with `(` followed by `key=`, so it would read as a key row")]
117    KeyRowShapedEntry(String),
118    #[error("stroke-dash `{0}` is neither 5 (`line=dashed`) nor 2 (`line=dotted`)")]
119    UnsayableStrokeDash(String),
120    #[error("style `{0}` has no property in the language")]
121    UnmappedStyleKey(String),
122    #[error("a badge edge already says `line=badge`, so it cannot also have a stroke-dash")]
123    BadgeWithStrokeDash,
124    #[error(
125        "link `{0}` holds no `:`, `/`, `?` or `#`, so the language would read it as a component reference; write it as `./{0}`"
126    )]
127    UrlWithoutUrlCharacter(String),
128    #[error(
129        "link `{link}` spells element `{target}` another way, and the language keeps only the path; write it as `{target}`"
130    )]
131    RespelledElementLink { link: String, target: ElementPath },
132    #[error("it joins an element to itself, which the language rejects")]
133    SelfLoop,
134    #[error("it joins an element to its own ancestor or descendant, which the language rejects")]
135    AncestorDescendantEdge,
136    #[error(
137        "its ordinal is {ordinal}, but the language numbers repeated edges in the order written, so it would read back as {reading_ordinal}"
138    )]
139    RenumberedEdge { ordinal: u32, reading_ordinal: u32 },
140    #[error("`{0}` names no element, so no reference points at it")]
141    UnknownReference(ElementPath),
142}
143
144/// Name the text of an element, edge or legend an unprintable reason is about.
145#[derive(Clone, Copy, Debug, PartialEq, Eq, strum::Display)]
146pub enum TextField {
147    #[strum(to_string = "display name")]
148    DisplayName,
149    #[strum(to_string = "caption")]
150    Caption,
151    #[strum(to_string = "link")]
152    Link,
153    #[strum(to_string = "metadata value")]
154    MetadataValue,
155    #[strum(to_string = "label")]
156    Label,
157    #[strum(to_string = "title")]
158    LegendTitle,
159    #[strum(to_string = "entry")]
160    LegendEntry,
161    #[strum(to_string = "dash description")]
162    DashDescription,
163    #[strum(to_string = "badge description")]
164    BadgeDescription,
165}
166
167/// Print a model as a Markdown diagram, collecting every part Markdown cannot say.
168pub struct MarkdownPrinter<'a> {
169    model: &'a Model,
170    references: ReferenceIndex<'a>,
171    markdown: String,
172    unprintables: Vec<Unprintable>,
173}
174
175impl<'a> MarkdownPrinter<'a> {
176    /// Create a new printer of `model` that has written nothing yet.
177    fn new(model: &'a Model) -> Self {
178        let references = ReferenceIndex::new(model);
179        Self {
180            model,
181            references,
182            markdown: String::new(),
183            unprintables: Vec::new(),
184        }
185    }
186
187    /// Print `model` as a Markdown diagram in canonical form, or report every part of it Markdown cannot say.
188    pub fn print(model: &'a Model) -> Result<String, UnprintableModel> {
189        let mut printer = Self::new(model);
190        printer.write_frontmatter();
191        printer.write_diagram_description();
192        printer.write_components();
193        printer.write_edges();
194        printer.write_legend();
195        printer.finish()
196    }
197
198    /// Write the frontmatter holding the title and subtitle, only when the model has either, each value double-quoted.
199    fn write_frontmatter(&mut self) {
200        let title = self.model.title();
201        let subtitle = self.model.subtitle();
202        let is_untitled = title.is_none();
203        let is_unsubtitled = subtitle.is_none();
204        let has_no_heading = is_untitled && is_unsubtitled;
205        if has_no_heading {
206            return;
207        }
208        self.markdown.push_str("---\n");
209        let frontmatter_entries = [("title", title), ("subtitle", subtitle)];
210        for (key, value) in frontmatter_entries {
211            let Some(value) = value else {
212                continue;
213            };
214            let yaml_value = Self::spell_yaml_quoted(value);
215            let entry = format!("{key}: {yaml_value}\n");
216            self.markdown.push_str(&entry);
217        }
218        self.markdown.push_str("---\n\n");
219    }
220
221    /// Write the diagram's description before `## Components`, or report one that would not read back verbatim.
222    fn write_diagram_description(&mut self) {
223        let Some(description) = self.model.description() else {
224            return;
225        };
226        let description_spelling = self.spell_description(description, "");
227        let mut reasons = Vec::new();
228        let spelled_description = Self::spelling_or_push_reason(description_spelling, &mut reasons);
229        self.markdown.push_str(&spelled_description);
230        self.markdown.push('\n');
231        self.report(reasons, || UnprintableOwner::Diagram);
232    }
233
234    /// Write one component item per element, in path order, nested two spaces per level.
235    fn write_components(&mut self) {
236        self.markdown.push_str("## Components\n");
237        let model = self.model;
238        for (path, element) in model.elements() {
239            let mut reasons = Vec::new();
240            let item = self.component_item(path, element, &mut reasons);
241            self.markdown.push_str(&item);
242            self.report(reasons, || {
243                let element_path = path.clone();
244                UnprintableOwner::Element(element_path)
245            });
246        }
247    }
248
249    /// Spell `- ID[: DISPLAY NAME] [(PROPERTIES)]` at the element's depth, the display name only when the label differs from the ID, then its description, pushing onto `reasons` what the language cannot say.
250    fn component_item(
251        &self,
252        path: &ElementPath,
253        element: &Element,
254        reasons: &mut Vec<UnprintableReason>,
255    ) -> String {
256        let id = path.name().unwrap_or_default();
257        let is_invalid_id = !Self::is_id(id);
258        if is_invalid_id {
259            let invalid_segment = id.to_owned();
260            let invalid_id = UnprintableReason::InvalidId(invalid_segment);
261            reasons.push(invalid_id);
262        }
263        let depth = path.depth();
264        let nesting_depth = depth.saturating_sub(1);
265        let indent = "  ".repeat(nesting_depth);
266        let label = element.label();
267        let is_label_diff_from_id = label != id;
268        let display_name = if is_label_diff_from_id {
269            let label_spelling = Self::spell_label(label, TextField::DisplayName);
270            let display_name = Self::spelling_or_push_reason(label_spelling, reasons);
271            format!(": {display_name}")
272        } else {
273            String::new()
274        };
275        let properties = self.component_properties(element, reasons);
276        let properties_list = Self::spell_properties_list(&properties);
277        let item_line = format!("{indent}- {id}{display_name}{properties_list}\n");
278        let Some(description) = element.description() else {
279            return item_line;
280        };
281        let description_indent = format!("{indent}  ");
282        let description_spelling = self.spell_description(description, &description_indent);
283        let spelled_description = Self::spelling_or_push_reason(description_spelling, reasons);
284        format!("{item_line}{spelled_description}")
285    }
286
287    /// Spell the element's properties in the language's table order, from `caption` to `meta.NAME`, pushing onto `reasons` what the language cannot say.
288    fn component_properties(
289        &self,
290        element: &Element,
291        reasons: &mut Vec<UnprintableReason>,
292    ) -> Vec<String> {
293        let mut properties = Vec::new();
294        let style = element.style();
295        if let Some(caption) = element.caption() {
296            let caption_spelling = Self::spell_value(caption, TextField::Caption);
297            let caption_value = Self::spelling_or_push_reason(caption_spelling, reasons);
298            let property = format!("caption={caption_value}");
299            properties.push(property);
300        }
301        if let Some(fill) = style.get(FILL) {
302            let property = format!("color={fill}");
303            properties.push(property);
304        }
305        if let Some(stroke) = style.get(STROKE) {
306            let property = format!("stroke={stroke}");
307            properties.push(property);
308        }
309        if let Some(stroke_dash) = style.get(STROKE_DASH) {
310            let line_spelling = Self::spell_line(stroke_dash);
311            let line = Self::spelling_or_push_reason(line_spelling, reasons);
312            let property = format!("line={line}");
313            properties.push(property);
314        }
315        if let Some(shape) = element.shape() {
316            let shape_keyword: &str = shape.into();
317            let property = format!("shape={shape_keyword}");
318            properties.push(property);
319        }
320        if let Some(height) = element.height() {
321            let property = format!("height={height}");
322            properties.push(property);
323        }
324        if let Some(position) = element.position() {
325            let x = position.x;
326            let y = position.y;
327            let property = format!("position={x},{y}");
328            properties.push(property);
329        }
330        if let Some(child_placement) = element.child_placement() {
331            let layout_keyword: &str = child_placement.into();
332            let property = format!("layout={layout_keyword}");
333            properties.push(property);
334        }
335        if let Some(PlacementHint::Grid { columns }) = element.child_placement() {
336            let property = format!("columns={columns}");
337            properties.push(property);
338        }
339        if let Some(raw_link) = element.link() {
340            let link_spelling = self.spell_link(raw_link);
341            let link_target = Self::spelling_or_push_reason(link_spelling, reasons);
342            let property = format!("link={link_target}");
343            properties.push(property);
344        }
345        for (name, value) in element.metadata() {
346            let is_invalid_name = !Self::is_id(name);
347            if is_invalid_name {
348                let invalid_metadata_name = name.clone();
349                let invalid_name = UnprintableReason::InvalidMetadataName(invalid_metadata_name);
350                reasons.push(invalid_name);
351            }
352            let metadata_spelling = Self::spell_value(value, TextField::MetadataValue);
353            let metadata_value = Self::spelling_or_push_reason(metadata_spelling, reasons);
354            let property = format!("meta.{name}={metadata_value}");
355            properties.push(property);
356        }
357        let unmapped_style_keys =
358            Self::find_unmapped_style_keys(style, &SAYABLE_ELEMENT_STYLE_KEYS);
359        reasons.extend(unmapped_style_keys);
360        properties
361    }
362
363    /// Spell a `link` value the way the language reads it back: an element by its shortest reference, a URL only when it holds a URL character.
364    fn spell_link(&self, raw_link: &str) -> Result<String, UnprintableReason> {
365        let link = Link::resolve(raw_link, self.model);
366        match link {
367            Link::Element(target) => {
368                let canonical_link = target.to_string();
369                let is_canonical = canonical_link == raw_link;
370                if is_canonical {
371                    return self.references.spell(&target);
372                }
373                let link = raw_link.to_owned();
374                let respelled_link = UnprintableReason::RespelledElementLink { link, target };
375                Err(respelled_link)
376            }
377            Link::Url(url) => {
378                let is_url_shaped = Link::is_url_shaped(url);
379                if is_url_shaped {
380                    return Self::spell_value(url, TextField::Link);
381                }
382                let unshaped_url = url.to_owned();
383                let url_without_url_character =
384                    UnprintableReason::UrlWithoutUrlCharacter(unshaped_url);
385                Err(url_without_url_character)
386            }
387        }
388    }
389
390    /// Write one edge item per edge, in edge order, under `## Edges` when the model has any.
391    fn write_edges(&mut self) {
392        let model = self.model;
393        let edges = model.edges();
394        let is_edgeless = edges.is_empty();
395        if is_edgeless {
396            return;
397        }
398        self.markdown.push_str("\n## Edges\n");
399        let mut previous_pair = None;
400        let mut reading_ordinal = 0;
401        for (id, edge) in edges {
402            let from = id.from();
403            let to = id.to();
404            let pair = (from, to);
405            let is_repeated_pair = previous_pair == Some(pair);
406            reading_ordinal = if is_repeated_pair {
407                reading_ordinal + 1
408            } else {
409                0
410            };
411            previous_pair = Some(pair);
412            let mut reasons = Vec::new();
413            let item = self.edge_item(id, edge, reading_ordinal, &mut reasons);
414            self.markdown.push_str(&item);
415            self.report(reasons, || {
416                let edge_id = id.clone();
417                UnprintableOwner::Edge(edge_id)
418            });
419        }
420    }
421
422    /// Spell `- FROM -> TO[: LABEL] [(PROPERTIES)]`, each endpoint by its shortest unique reference, then its description, pushing onto `reasons` what the language cannot say, an edge it would number other than `reading_ordinal` included.
423    fn edge_item(
424        &self,
425        id: &EdgeId,
426        edge: &Edge,
427        reading_ordinal: u32,
428        reasons: &mut Vec<UnprintableReason>,
429    ) -> String {
430        let from = id.from();
431        let to = id.to();
432        let is_self_loop = from == to;
433        if is_self_loop {
434            reasons.push(UnprintableReason::SelfLoop);
435        }
436        let joins_ancestor_and_descendant = id.joins_ancestor_and_descendant();
437        if joins_ancestor_and_descendant {
438            reasons.push(UnprintableReason::AncestorDescendantEdge);
439        }
440        let ordinal = id.ordinal();
441        let is_renumbered = ordinal != reading_ordinal;
442        if is_renumbered {
443            let renumbered_edge = UnprintableReason::RenumberedEdge {
444                ordinal,
445                reading_ordinal,
446            };
447            reasons.push(renumbered_edge);
448        }
449        let from_spelling = self.references.spell(from);
450        let from_reference = Self::spelling_or_push_reason(from_spelling, reasons);
451        let to_spelling = self.references.spell(to);
452        let to_reference = Self::spelling_or_push_reason(to_spelling, reasons);
453        let label = match edge.label() {
454            Some(label) => {
455                let label_spelling = Self::spell_label(label, TextField::Label);
456                let label = Self::spelling_or_push_reason(label_spelling, reasons);
457                format!(": {label}")
458            }
459            None => String::new(),
460        };
461        let properties = Self::edge_properties(edge, reasons);
462        let properties_list = Self::spell_properties_list(&properties);
463        let item_line = format!("- {from_reference} -> {to_reference}{label}{properties_list}\n");
464        let Some(description) = edge.description() else {
465            return item_line;
466        };
467        let description_spelling = self.spell_description(description, "  ");
468        let spelled_description = Self::spelling_or_push_reason(description_spelling, reasons);
469        format!("{item_line}{spelled_description}")
470    }
471
472    /// Spell the edge's properties in the language's table order, `line`, `color`, `traveler`, pushing onto `reasons` what the language cannot say.
473    fn edge_properties(edge: &Edge, reasons: &mut Vec<UnprintableReason>) -> Vec<String> {
474        let mut properties = Vec::new();
475        let style = edge.style();
476        let is_badge = edge.is_badge();
477        let stroke_dash = style.get(STROKE_DASH);
478        match (is_badge, stroke_dash) {
479            (true, Some(_stroke_dash)) => reasons.push(UnprintableReason::BadgeWithStrokeDash),
480            (true, None) => {
481                let badge: &str = LineStyle::Badge.into();
482                let property = format!("line={badge}");
483                properties.push(property);
484            }
485            (false, Some(stroke_dash)) => {
486                let line_spelling = Self::spell_line(stroke_dash);
487                let line = Self::spelling_or_push_reason(line_spelling, reasons);
488                let property = format!("line={line}");
489                properties.push(property);
490            }
491            (false, None) => {}
492        }
493        if let Some(stroke) = style.get(STROKE) {
494            let property = format!("color={stroke}");
495            properties.push(property);
496        }
497        if let Some(traveler) = edge.traveler() {
498            let traveler_keyword: &str = traveler.into();
499            let property = format!("traveler={traveler_keyword}");
500            properties.push(property);
501        }
502        let unmapped_style_keys = Self::find_unmapped_style_keys(style, &SAYABLE_EDGE_STYLE_KEYS);
503        reasons.extend(unmapped_style_keys);
504        properties
505    }
506
507    /// Write the legend section: its free-text entries, then a key row per dash and per badge description, each written verbatim or reported.
508    fn write_legend(&mut self) {
509        let model = self.model;
510        let Some(legend) = model.legend() else {
511            return;
512        };
513        let mut reasons = Vec::new();
514        let heading = match legend.title() {
515            Some(title) => {
516                let title_problem = Self::check_legend_title(title);
517                reasons.extend(title_problem);
518                let spaced_title = Self::spaced(title);
519                format!("\n## Legend:{spaced_title}\n")
520            }
521            None => "\n## Legend\n".to_owned(),
522        };
523        self.markdown.push_str(&heading);
524        for entry in legend.entries() {
525            let entry_problem = Self::check_legend_entry(entry);
526            reasons.extend(entry_problem);
527            let item = format!("- {entry}\n");
528            self.markdown.push_str(&item);
529        }
530        for (dash_key, description) in legend.dashes() {
531            let dash_spelling = dash_key.to_string();
532            let line_spelling = Self::spell_line(&dash_spelling);
533            let line = Self::spelling_or_push_reason(line_spelling, &mut reasons);
534            let description_problem =
535                Self::check_verbatim_text(description, TextField::DashDescription);
536            reasons.extend(description_problem);
537            let spaced_description = Self::spaced(description);
538            let item = format!("- (line={line}):{spaced_description}\n");
539            self.markdown.push_str(&item);
540        }
541        for (target, description) in legend.badges() {
542            let target_spelling = self.references.spell(target);
543            let target_reference = Self::spelling_or_push_reason(target_spelling, &mut reasons);
544            let description_problem =
545                Self::check_verbatim_text(description, TextField::BadgeDescription);
546            reasons.extend(description_problem);
547            let spaced_description = Self::spaced(description);
548            let item = format!("- (line=badge, target={target_reference}):{spaced_description}\n");
549            self.markdown.push_str(&item);
550        }
551        self.report(reasons, || UnprintableOwner::Legend);
552    }
553
554    /// Check that a legend title reads back from its heading as itself: verbatim text not ending in a closing `#`.
555    fn check_legend_title(title: &str) -> Option<UnprintableReason> {
556        let verbatim_problem = Self::check_verbatim_text(title, TextField::LegendTitle);
557        let has_verbatim_problem = verbatim_problem.is_some();
558        if has_verbatim_problem {
559            return verbatim_problem;
560        }
561        let is_ending_with_hash = title.ends_with('#');
562        if is_ending_with_hash {
563            return Some(UnprintableReason::TitleEndingWithHash);
564        }
565        None
566    }
567
568    /// Check that a free-text legend entry reads back as itself: non-empty verbatim text that opens no Markdown block and no key row.
569    fn check_legend_entry(entry: &str) -> Option<UnprintableReason> {
570        let is_empty = entry.is_empty();
571        if is_empty {
572            return Some(UnprintableReason::EmptyEntry);
573        }
574        let verbatim_problem = Self::check_verbatim_text(entry, TextField::LegendEntry);
575        let has_verbatim_problem = verbatim_problem.is_some();
576        if has_verbatim_problem {
577            return verbatim_problem;
578        }
579        let opens_markdown_block = Self::opens_markdown_block(entry);
580        if opens_markdown_block {
581            let block_opening_text = entry.to_owned();
582            let block_opening_entry = UnprintableReason::BlockOpeningEntry(block_opening_text);
583            return Some(block_opening_entry);
584        }
585        let is_key_row_shaped = Self::is_key_row_shaped(entry);
586        if is_key_row_shaped {
587            let key_row_shaped_text = entry.to_owned();
588            let key_row_shaped_entry = UnprintableReason::KeyRowShapedEntry(key_row_shaped_text);
589            return Some(key_row_shaped_entry);
590        }
591        None
592    }
593
594    /// Check that `text` reads back verbatim from the rest of a line, which the parser trims: one-line text with no surrounding whitespace.
595    fn check_verbatim_text(text: &str, field: TextField) -> Option<UnprintableReason> {
596        let one_line_problem = Self::check_one_line_text(text, field);
597        let has_one_line_problem = one_line_problem.is_some();
598        if has_one_line_problem {
599            return one_line_problem;
600        }
601        let trimmed_text = text.trim();
602        let is_padded = trimmed_text != text;
603        if is_padded {
604            let text = text.to_owned();
605            let padded_text = UnprintableReason::PaddedText { field, text };
606            return Some(padded_text);
607        }
608        None
609    }
610
611    /// Check that `text` survives inside one item line: no line break ends it and no comment opening drops it.
612    fn check_one_line_text(text: &str, field: TextField) -> Option<UnprintableReason> {
613        let has_line_break = text.contains(LINE_BREAKS);
614        if has_line_break {
615            let text = text.to_owned();
616            let line_break = UnprintableReason::LineBreak { field, text };
617            return Some(line_break);
618        }
619        let has_comment_opening = text.contains(COMMENT_OPENING);
620        if has_comment_opening {
621            let text = text.to_owned();
622            let comment_opening = UnprintableReason::CommentOpening { field, text };
623            return Some(comment_opening);
624        }
625        None
626    }
627
628    /// Tell whether CommonMark may read `text`, at the start of a list item, as a block other than a paragraph.
629    fn opens_markdown_block(text: &str) -> bool {
630        let starts_with_block_marker = text.starts_with(BLOCK_MARKERS);
631        let after_leading_digits =
632            text.trim_start_matches(|character: char| character.is_ascii_digit());
633        let has_leading_digits = after_leading_digits.len() < text.len();
634        let has_list_delimiter_after_digits =
635            after_leading_digits.starts_with(ORDERED_LIST_DELIMITERS);
636        let opens_ordered_list = has_leading_digits && has_list_delimiter_after_digits;
637        starts_with_block_marker || opens_ordered_list
638    }
639
640    /// Tell whether `text` opens like a key row, `(` followed by `key=`, spaces allowed, which the parser reads as one.
641    fn is_key_row_shaped(text: &str) -> bool {
642        let Some(after_parenthesis) = text.strip_prefix('(') else {
643            return false;
644        };
645        let key_onwards = after_parenthesis.trim_start();
646        let after_key = key_onwards.trim_start_matches(|character: char| {
647            let is_alphanumeric = character.is_ascii_alphanumeric();
648            let is_key_punctuation = matches!(character, '_' | '-' | '.');
649            is_alphanumeric || is_key_punctuation
650        });
651        let has_key = after_key.len() < key_onwards.len();
652        let equals_onwards = after_key.trim_start();
653        let has_equals_after_key = equals_onwards.starts_with('=');
654        has_key && has_equals_after_key
655    }
656
657    /// Hand out the written Markdown, or every unprintable part found while writing it.
658    fn finish(self) -> Result<String, UnprintableModel> {
659        let is_printable = self.unprintables.is_empty();
660        if is_printable {
661            return Ok(self.markdown);
662        }
663        let unprintables = self.unprintables;
664        let unprintable_model = UnprintableModel { unprintables };
665        Err(unprintable_model)
666    }
667
668    /// Record each reason as an unprintable part of `owner`, building the owner only when there is a reason.
669    fn report(&mut self, reasons: Vec<UnprintableReason>, owner: impl Fn() -> UnprintableOwner) {
670        let unprintables = reasons.into_iter().map(|reason| {
671            let owner = owner();
672            Unprintable { owner, reason }
673        });
674        self.unprintables.extend(unprintables);
675    }
676
677    /// Return the spelling, or push the reason there is none onto `reasons` and return an empty spelling, which an unprintable model never shows.
678    fn spelling_or_push_reason<T: Default>(
679        spelling: Result<T, UnprintableReason>,
680        reasons: &mut Vec<UnprintableReason>,
681    ) -> T {
682        spelling.unwrap_or_else(|reason| {
683            reasons.push(reason);
684            T::default()
685        })
686    }
687
688    /// Find every style key of `style` outside `sayable_keys`, the keys no language property lowers to.
689    fn find_unmapped_style_keys(
690        style: &BTreeMap<String, String>,
691        sayable_keys: &[&str],
692    ) -> impl Iterator<Item = UnprintableReason> {
693        let style_keys = style.keys();
694        let unmapped_keys = style_keys.filter(|key| {
695            let is_sayable = sayable_keys.contains(&key.as_str());
696            !is_sayable
697        });
698        unmapped_keys
699            .cloned()
700            .map(UnprintableReason::UnmappedStyleKey)
701    }
702
703    /// Spell a stroke-dash as the `line` keyword drawing it, or report a length no keyword draws.
704    fn spell_line(stroke_dash: &str) -> Result<&'static str, UnprintableReason> {
705        let dash_length = NumberValue::parse(stroke_dash);
706        let line_style = dash_length.and_then(LineStyle::drawing_dash);
707        let Some(line_style) = line_style else {
708            let unsayable_dash = stroke_dash.to_owned();
709            let unsayable_stroke_dash = UnprintableReason::UnsayableStrokeDash(unsayable_dash);
710            return Err(unsayable_stroke_dash);
711        };
712        let line_keyword: &'static str = line_style.into();
713        Ok(line_keyword)
714    }
715
716    /// Spell `text` as a value: bare when it reads back as itself, otherwise quoted.
717    fn spell_value(text: &str, field: TextField) -> Result<String, UnprintableReason> {
718        let reads_back_bare = Value::reads_back_bare(text);
719        Self::spell_bare_or_quoted(text, reads_back_bare, field)
720    }
721
722    /// Spell `text` as a label or display name: bare when it reads back as itself, otherwise quoted.
723    fn spell_label(text: &str, field: TextField) -> Result<String, UnprintableReason> {
724        let reads_back_bare = ItemLabel::reads_back_bare(text);
725        Self::spell_bare_or_quoted(text, reads_back_bare, field)
726    }
727
728    /// Spell `text` bare when `reads_back_bare`, otherwise quoted, or report text no item line keeps.
729    fn spell_bare_or_quoted(
730        text: &str,
731        reads_back_bare: bool,
732        field: TextField,
733    ) -> Result<String, UnprintableReason> {
734        let one_line_problem = Self::check_one_line_text(text, field);
735        if let Some(one_line_problem) = one_line_problem {
736            return Err(one_line_problem);
737        }
738        if reads_back_bare {
739            let bare_text = text.to_owned();
740            return Ok(bare_text);
741        }
742        Self::spell_quoted(text, field)
743    }
744
745    /// Spell `text` as a quoted string, escaping `"` and `\`, its only escapes, or report text no item line keeps.
746    fn spell_quoted(text: &str, field: TextField) -> Result<String, UnprintableReason> {
747        let one_line_problem = Self::check_one_line_text(text, field);
748        if let Some(one_line_problem) = one_line_problem {
749            return Err(one_line_problem);
750        }
751        let backslash_escaped = text.replace('\\', "\\\\");
752        let escaped = backslash_escaped.replace('"', "\\\"");
753        let quoted = format!("\"{escaped}\"");
754        Ok(quoted)
755    }
756
757    /// Spell `description` as a description block after `indent`, its targets respelled as references, or report a description that would not read back verbatim under an item.
758    fn spell_description(
759        &self,
760        description: &str,
761        indent: &str,
762    ) -> Result<String, UnprintableReason> {
763        let respelled_description = self.respell_targets(description);
764        let description = respelled_description.as_str();
765        let item_spelling = Self::fenced_description(description, "  ");
766        let reads_back = Skeleton::reads_back_as_description(description, &item_spelling);
767        let is_lossy = !reads_back;
768        if is_lossy {
769            return Err(UnprintableReason::UnreadableDescription);
770        }
771        let spelled_description = Self::fenced_description(description, indent);
772        Ok(spelled_description)
773    }
774
775    /// Respell each canonical `#` target of `description` by its shortest unique references, keeping one that names nothing in the model as written.
776    fn respell_targets(&self, description: &str) -> String {
777        TargetLink::respell_all(description, |target_link| {
778            let description_target = DescriptionTarget::parse(target_link.target)?;
779            match description_target {
780                DescriptionTarget::Element(path) => self.references.spell(&path).ok(),
781                DescriptionTarget::Edge(id) => self.spell_edge_target(&id),
782            }
783        })
784    }
785
786    /// Spell an edge target `FROM->TO[n]` by its endpoints' shortest references, `[n]` dropped when the edge is the only one between them; `None` for an edge not in the model.
787    fn spell_edge_target(&self, id: &EdgeId) -> Option<String> {
788        self.model.edge(id)?;
789        let from = id.from();
790        let to = id.to();
791        let from_reference = self.references.spell(from).ok()?;
792        let to_reference = self.references.spell(to).ok()?;
793        let edge_ids = self.model.edges().keys();
794        let parallel_edge_count = edge_ids
795            .filter(|edge_id| edge_id.from() == from && edge_id.to() == to)
796            .count();
797        let is_only_edge_of_pair = parallel_edge_count == 1;
798        if is_only_edge_of_pair {
799            let unnumbered_target = format!("{from_reference}->{to_reference}");
800            return Some(unnumbered_target);
801        }
802        let ordinal = id.ordinal();
803        let numbered_target = format!("{from_reference}->{to_reference}[{ordinal}]");
804        Some(numbered_target)
805    }
806
807    /// Fence `description` as a description block after `indent`; fence length = max(3, 1 + longest backtick run opening a line).
808    fn fenced_description(description: &str, indent: &str) -> String {
809        let opening_backtick_runs = description.split('\n').map(|line| {
810            let after_spaces = line.trim_start_matches(' ');
811            let after_backticks = after_spaces.trim_start_matches('`');
812            after_spaces.len() - after_backticks.len()
813        });
814        let longest_opening_run = opening_backtick_runs.max().unwrap_or_default();
815        let fence_length = MIN_FENCE_LENGTH.max(longest_opening_run + 1);
816        let fence = "`".repeat(fence_length);
817        let content = Self::indented_lines(description, indent);
818        format!("{indent}{fence}{DESCRIPTION_INFO}\n{content}{indent}{fence}\n")
819    }
820
821    /// Spell each line of `text` after `indent`, an empty line left empty, each ending with a line break.
822    fn indented_lines(text: &str, indent: &str) -> String {
823        let lines = text.split('\n').map(|line| {
824            let is_empty_line = line.is_empty();
825            if is_empty_line {
826                return "\n".to_owned();
827            }
828            format!("{indent}{line}\n")
829        });
830        lines.collect()
831    }
832
833    /// Spell a properties list as ` (a, b)`, or as nothing when there is no property.
834    fn spell_properties_list(properties: &[String]) -> String {
835        let is_empty = properties.is_empty();
836        if is_empty {
837            return String::new();
838        }
839        let entries = properties.join(", ");
840        format!(" ({entries})")
841    }
842
843    /// Spell `text` after the space that separates it from what precedes, or as nothing when it is empty.
844    fn spaced(text: &str) -> String {
845        let is_empty = text.is_empty();
846        if is_empty {
847            return String::new();
848        }
849        format!(" {text}")
850    }
851
852    /// Tell whether `text` is an ID: ASCII letters, digits, `_` and `-`, starting with a letter or `_`, each `-` followed by a non-`-`.
853    fn is_id(text: &str) -> bool {
854        let mut characters = text.chars();
855        let Some(first_character) = characters.next() else {
856            return false;
857        };
858        let is_valid_start = matches!(first_character, 'a'..='z' | 'A'..='Z' | '_');
859        let is_valid_rest = characters
860            .all(|character| matches!(character, 'a'..='z' | 'A'..='Z' | '0'..='9' | '_' | '-'));
861        let has_double_hyphen = text.contains("--");
862        let has_trailing_hyphen = text.ends_with('-');
863        let has_dangling_hyphen = has_double_hyphen || has_trailing_hyphen;
864        is_valid_start && is_valid_rest && !has_dangling_hyphen
865    }
866
867    /// Spell `text` as a YAML double-quoted scalar, escaping quotes, backslashes and every character YAML forbids unescaped.
868    fn spell_yaml_quoted(text: &str) -> String {
869        let mut quoted = String::from('"');
870        for character in text.chars() {
871            let is_unprintable_in_yaml =
872                character.is_control() || matches!(character, '\u{FFFE}' | '\u{FFFF}');
873            match character {
874                '"' => quoted.push_str("\\\""),
875                '\\' => quoted.push_str("\\\\"),
876                unprintable if is_unprintable_in_yaml => {
877                    let code_point = u32::from(unprintable);
878                    let escape = format!("\\u{code_point:04X}");
879                    quoted.push_str(&escape);
880                }
881                printable => quoted.push(printable),
882            }
883        }
884        quoted.push('"');
885        quoted
886    }
887}
888
889/// Count the elements each dotted suffix of a path matches, to spell every element by its shortest unique reference.
890struct ReferenceIndex<'a> {
891    model: &'a Model,
892    element_counts_by_suffix: HashMap<&'a [String], usize>,
893}
894
895impl<'a> ReferenceIndex<'a> {
896    /// Index every dotted suffix of every element path of `model`.
897    fn new(model: &'a Model) -> Self {
898        let mut element_counts_by_suffix = HashMap::new();
899        let elements = model.elements();
900        for path in elements.keys() {
901            let segments = path.segments();
902            let depth = segments.len();
903            let suffix_starts = 0..depth;
904            for suffix_start in suffix_starts {
905                let suffix = &segments[suffix_start..];
906                let element_count = element_counts_by_suffix.entry(suffix).or_default();
907                *element_count += 1;
908            }
909        }
910        Self {
911            model,
912            element_counts_by_suffix,
913        }
914    }
915
916    /// Spell the shortest dotted suffix of `path` that ends no other element's path, else the whole path, which always names it exactly.
917    fn spell(&self, path: &ElementPath) -> Result<String, UnprintableReason> {
918        let element = self.model.element(path);
919        let is_unknown_element = element.is_none();
920        if is_unknown_element {
921            let unknown_path = path.clone();
922            let unknown_reference = UnprintableReason::UnknownReference(unknown_path);
923            return Err(unknown_reference);
924        }
925        let segments = path.segments();
926        let depth = segments.len();
927        let suffix_starts = 0..depth;
928        let shortest_first_starts = suffix_starts.rev();
929        let suffixes = shortest_first_starts.map(|suffix_start| &segments[suffix_start..]);
930        let mut unique_suffixes = suffixes.filter(|suffix| {
931            let element_count = self.element_counts_by_suffix.get(suffix);
932            element_count == Some(&1)
933        });
934        let Some(unique_suffix) = unique_suffixes.next() else {
935            let whole_path = segments.join(".");
936            return Ok(whole_path);
937        };
938        let reference = unique_suffix.join(".");
939        Ok(reference)
940    }
941}