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, BTreeSet, 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, Position};
11use svugy_model::path::ElementPath;
12use svugy_model::properties::ComponentProperties;
13use svugy_model::style::{ColorKey, NumberKey, NumberValue, StyleValue};
14use svugy_model::workflow::{ElementOverride, Step, StepId, StepItem, Workflow, WorkflowId};
15
16use super::ast::Value;
17use super::parse::ItemLabel;
18use super::property::LineStyle;
19use super::skeleton::{DESCRIPTION_INFO, Skeleton};
20use super::target::{ComponentNumbering, EdgeNumbering};
21
22/// Name the style key an element's `color` lowers to.
23const FILL: &str = ColorKey::FILL.name();
24/// Name the style key an element's `stroke` and an edge's `color` lower to.
25const STROKE: &str = ColorKey::STROKE.name();
26/// Name the style key `line=dashed` and `line=dotted` lower to.
27const STROKE_DASH: &str = NumberKey::STROKE_DASH.name();
28/// List the element style keys a language property says: `color`, `stroke` and `line`.
29const SAYABLE_ELEMENT_STYLE_KEYS: [&str; 3] = [FILL, STROKE, STROKE_DASH];
30/// List the edge style keys a language property says: `color` and `line`.
31const SAYABLE_EDGE_STYLE_KEYS: [&str; 2] = [STROKE, STROKE_DASH];
32/// List the characters that end a one-line list item.
33const LINE_BREAKS: [char; 2] = ['\n', '\r'];
34/// Spell the opening of an HTML comment, which the language drops wherever it appears.
35const COMMENT_OPENING: &str = "<!--";
36/// Count the backticks of the shortest code fence CommonMark reads.
37const MIN_FENCE_LENGTH: usize = 3;
38
39/// Report every part of a model Markdown cannot say, so one round fixes them all.
40#[derive(Clone, Debug, PartialEq, Error)]
41#[error(
42    "Markdown cannot say {} part(s) of this model:{}",
43    .unprintables.len(),
44    Self::bulleted(.unprintables)
45)]
46pub struct UnprintableModel {
47    pub unprintables: Vec<Unprintable>,
48}
49
50impl UnprintableModel {
51    /// Spell each unprintable part on a line of its own, opened by `- `.
52    fn bulleted(unprintables: &[Unprintable]) -> String {
53        let lines = unprintables
54            .iter()
55            .map(|unprintable| format!("\n- {unprintable}"));
56        lines.collect()
57    }
58}
59
60/// Name one part of a model Markdown cannot say, with the element, workflow, override, step or edge it belongs to.
61#[derive(Clone, Debug, PartialEq, Error)]
62#[error("{owner}: {reason}")]
63pub struct Unprintable {
64    pub owner: UnprintableOwner,
65    pub reason: UnprintableReason,
66}
67
68/// Name the element, workflow, override, step or workflow edge an unprintable part belongs to.
69#[derive(Clone, Debug, PartialEq)]
70pub enum UnprintableOwner {
71    Element(ElementPath),
72    Workflow(WorkflowId),
73    Override(WorkflowId, ElementPath),
74    Step(WorkflowId, StepId),
75    Edge(WorkflowId, EdgeId),
76}
77
78impl fmt::Display for UnprintableOwner {
79    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
80        match self {
81            Self::Element(path) => write!(formatter, "element `{path}`"),
82            Self::Workflow(workflow) => write!(formatter, "workflow `{workflow}`"),
83            Self::Override(workflow, path) => {
84                write!(formatter, "override of `{path}` in workflow `{workflow}`")
85            }
86            Self::Step(workflow, step) => write!(formatter, "step `{workflow}/{step}`"),
87            Self::Edge(workflow, id) => write!(formatter, "edge `{id}` of workflow `{workflow}`"),
88        }
89    }
90}
91
92/// Explain why Markdown cannot say one part of a model.
93#[derive(Clone, Debug, PartialEq, Error)]
94pub enum UnprintableReason {
95    #[error(
96        "path segment `{0}` is not an ID: ASCII letters, digits, `_` and single inner `-`, starting with a letter or `_`"
97    )]
98    InvalidId(String),
99    #[error("metadata name `{0}` is not an ID, so no `meta.NAME` key spells it")]
100    InvalidMetadataName(String),
101    #[error("its {field} {text:?} holds a line break, which no one-line item can")]
102    LineBreak { field: TextField, text: String },
103    #[error("its {field} {text:?} holds `<!--`, which the language reads as a comment and drops")]
104    CommentOpening { field: TextField, text: String },
105    #[error(
106        "its description does not read back verbatim from a ```description block: Markdown reads a carriage return as a line break"
107    )]
108    UnreadableDescription,
109    #[error(
110        "its prose does not read back verbatim under a heading: outside a code fence it holds no `- ` bullet list, `#` heading or comment, and no blank line opens or closes it"
111    )]
112    UnreadableProse,
113    #[error("stroke-dash `{0}` is neither 5 (`line=dashed`) nor 2 (`line=dotted`)")]
114    UnsayableStrokeDash(String),
115    #[error("style `{0}` has no property in the language")]
116    UnmappedStyleKey(String),
117    #[error("a badge edge already says `line=badge`, so it cannot also have a stroke-dash")]
118    BadgeWithStrokeDash,
119    #[error(
120        "link `{0}` holds no `:`, `/`, `?` or `#`, so the language would read it as a component reference; write it as `./{0}`"
121    )]
122    UrlWithoutUrlCharacter(String),
123    #[error(
124        "link `{link}` spells element `{target}` another way, and the language keeps only the path; write it as `{target}`"
125    )]
126    RespelledElementLink { link: String, target: ElementPath },
127    #[error(
128        "its ordinal is {ordinal}, but the language numbers repeated edges in the order their steps declare them, so it would read back as {reading_ordinal}"
129    )]
130    RenumberedEdge { ordinal: u32, reading_ordinal: u32 },
131}
132
133/// Name the text of an element, workflow, step or edge an unprintable reason is about.
134#[derive(Clone, Copy, Debug, PartialEq, Eq, strum::Display)]
135pub enum TextField {
136    #[strum(to_string = "display name")]
137    DisplayName,
138    #[strum(to_string = "caption")]
139    Caption,
140    #[strum(to_string = "link")]
141    Link,
142    #[strum(to_string = "code")]
143    Code,
144    #[strum(to_string = "metadata value")]
145    MetadataValue,
146    #[strum(to_string = "label")]
147    Label,
148    #[strum(to_string = "title")]
149    Title,
150    #[strum(to_string = "subtitle")]
151    Subtitle,
152}
153
154/// Print a model as a Markdown diagram, collecting every part Markdown cannot say.
155pub struct MarkdownPrinter<'a> {
156    model: &'a Model,
157    references: ReferenceIndex<'a>,
158    markdown: String,
159    unprintables: Vec<Unprintable>,
160}
161
162/// Track which edges the printed steps of one workflow have declared, the ordinal Markdown reads each declaration back with, and how many listings of each edge are still to print.
163struct EdgeDeclarations<'a> {
164    declared_edges: BTreeSet<&'a EdgeId>,
165    declaration_counts: BTreeMap<(&'a ElementPath, &'a ElementPath), u32>,
166    listings_left: BTreeMap<&'a EdgeId, usize>,
167}
168
169impl<'a> MarkdownPrinter<'a> {
170    /// Create a new printer of `model` that has written nothing yet.
171    fn new(model: &'a Model) -> Self {
172        let references = ReferenceIndex::new(model);
173        Self {
174            model,
175            references,
176            markdown: String::new(),
177            unprintables: Vec::new(),
178        }
179    }
180
181    /// Print `model` as a Markdown diagram in canonical form, or report every part of it Markdown cannot say.
182    pub fn print(model: &'a Model) -> Result<String, UnprintableModel> {
183        let mut printer = Self::new(model);
184        printer.write_frontmatter();
185        printer.write_components();
186        printer.write_workflows();
187        printer.finish()
188    }
189
190    /// Write the frontmatter holding the view angle, only when the model sets one.
191    fn write_frontmatter(&mut self) {
192        let Some(view) = self.model.view() else {
193            return;
194        };
195        let view_spelling: &'static str = view.into();
196        let frontmatter = format!("---\nview: {view_spelling}\n---\n\n");
197        self.markdown.push_str(&frontmatter);
198    }
199
200    /// Write one component item per element, in path order, nested two spaces per level.
201    fn write_components(&mut self) {
202        self.markdown.push_str("## Components\n");
203        let model = self.model;
204        for (path, element) in model.elements() {
205            let mut reasons = Vec::new();
206            let item = self.component_item(path, element, &mut reasons);
207            self.markdown.push_str(&item);
208            self.report(reasons, || {
209                let element_path = path.clone();
210                UnprintableOwner::Element(element_path)
211            });
212        }
213    }
214
215    /// 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.
216    fn component_item(
217        &self,
218        path: &ElementPath,
219        element: &Element,
220        reasons: &mut Vec<UnprintableReason>,
221    ) -> String {
222        let id = path.name().unwrap_or_default();
223        let is_invalid_id = !ElementPath::is_component_id(id);
224        if is_invalid_id {
225            let invalid_segment = id.to_owned();
226            let invalid_id = UnprintableReason::InvalidId(invalid_segment);
227            reasons.push(invalid_id);
228        }
229        let depth = path.depth();
230        let nesting_depth = depth.saturating_sub(1);
231        let indent = "  ".repeat(nesting_depth);
232        let label = element.label();
233        let is_label_diff_from_id = label != id;
234        let display_name = if is_label_diff_from_id {
235            let label_spelling = Self::spell_label(label, TextField::DisplayName);
236            let display_name = Self::spelling_or_push_reason(label_spelling, reasons);
237            format!(": {display_name}")
238        } else {
239            String::new()
240        };
241        let element_properties = element.properties();
242        let properties = self.component_properties(element_properties, None, None, reasons);
243        let properties_list = Self::spell_properties_list(&properties);
244        let item_line = format!("{indent}- {id}{display_name}{properties_list}\n");
245        let Some(description) = element.description() else {
246            return item_line;
247        };
248        let description_indent = format!("{indent}  ");
249        let description_spelling =
250            self.spell_description(description, &description_indent, &ComponentNumbering);
251        let spelled_description = Self::spelling_or_push_reason(description_spelling, reasons);
252        format!("{item_line}{spelled_description}")
253    }
254
255    /// Spell a component's properties, with the `position` and `child_placement` only an override sets, in the language's table order from `caption` to `meta.NAME`, pushing onto `reasons` what the language cannot say.
256    fn component_properties(
257        &self,
258        component: &ComponentProperties,
259        position: Option<Position>,
260        child_placement: Option<PlacementHint>,
261        reasons: &mut Vec<UnprintableReason>,
262    ) -> Vec<String> {
263        let mut properties = Vec::new();
264        let style = component.style();
265        if let Some(caption) = component.caption() {
266            let caption_spelling = Self::spell_value(caption, TextField::Caption);
267            let caption_value = Self::spelling_or_push_reason(caption_spelling, reasons);
268            let property = format!("caption={caption_value}");
269            properties.push(property);
270        }
271        if let Some(fill) = style.get(FILL) {
272            let property = format!("color={fill}");
273            properties.push(property);
274        }
275        if let Some(stroke) = style.get(STROKE) {
276            let property = format!("stroke={stroke}");
277            properties.push(property);
278        }
279        if let Some(stroke_dash) = style.get(STROKE_DASH) {
280            let line_spelling = Self::spell_line(stroke_dash);
281            let line = Self::spelling_or_push_reason(line_spelling, reasons);
282            let property = format!("line={line}");
283            properties.push(property);
284        }
285        if let Some(shape) = component.shape() {
286            let shape_keyword: &str = shape.into();
287            let property = format!("shape={shape_keyword}");
288            properties.push(property);
289        }
290        if let Some(height) = component.height() {
291            let property = format!("height={height}");
292            properties.push(property);
293        }
294        if let Some(position) = position {
295            let x = position.x;
296            let y = position.y;
297            let property = format!("position={x},{y}");
298            properties.push(property);
299        }
300        if let Some(child_placement) = child_placement {
301            let layout_keyword: &str = child_placement.into();
302            let property = format!("layout={layout_keyword}");
303            properties.push(property);
304        }
305        if let Some(PlacementHint::Grid { columns }) = child_placement {
306            let property = format!("columns={columns}");
307            properties.push(property);
308        }
309        if let Some(raw_link) = component.link() {
310            let link_spelling = self.spell_link(raw_link);
311            let link_target = Self::spelling_or_push_reason(link_spelling, reasons);
312            let property = format!("link={link_target}");
313            properties.push(property);
314        }
315        if let Some(code) = component.code() {
316            let code_spelling = Self::spell_value(code, TextField::Code);
317            let code_value = Self::spelling_or_push_reason(code_spelling, reasons);
318            let property = format!("code={code_value}");
319            properties.push(property);
320        }
321        for (name, value) in component.metadata() {
322            let is_invalid_name = !ElementPath::is_component_id(name);
323            if is_invalid_name {
324                let invalid_metadata_name = name.clone();
325                let invalid_name = UnprintableReason::InvalidMetadataName(invalid_metadata_name);
326                reasons.push(invalid_name);
327            }
328            let metadata_spelling = Self::spell_value(value, TextField::MetadataValue);
329            let metadata_value = Self::spelling_or_push_reason(metadata_spelling, reasons);
330            let property = format!("meta.{name}={metadata_value}");
331            properties.push(property);
332        }
333        let unmapped_style_keys =
334            Self::find_unmapped_style_keys(style, &SAYABLE_ELEMENT_STYLE_KEYS);
335        reasons.extend(unmapped_style_keys);
336        properties
337    }
338
339    /// 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.
340    fn spell_link(&self, raw_link: &str) -> Result<String, UnprintableReason> {
341        let elements = self.model.elements();
342        let link = Link::resolve(raw_link, elements);
343        match link {
344            Link::Element(target) => {
345                let canonical_link = target.to_string();
346                let is_canonical = canonical_link == raw_link;
347                if is_canonical {
348                    let reference = self.references.spell(&target);
349                    return Ok(reference);
350                }
351                let link = raw_link.to_owned();
352                let respelled_link = UnprintableReason::RespelledElementLink { link, target };
353                Err(respelled_link)
354            }
355            Link::Url(url) => {
356                let is_url_shaped = Link::is_url_shaped(url);
357                if is_url_shaped {
358                    return Self::spell_value(url, TextField::Link);
359                }
360                let unshaped_url = url.to_owned();
361                let url_without_url_character =
362                    UnprintableReason::UrlWithoutUrlCharacter(unshaped_url);
363                Err(url_without_url_character)
364            }
365        }
366    }
367
368    /// Write `## Workflows`, then each workflow in written order.
369    fn write_workflows(&mut self) {
370        self.markdown.push_str("\n## Workflows\n");
371        let model = self.model;
372        for workflow in model.workflows() {
373            self.write_workflow(workflow);
374        }
375    }
376
377    /// Write the workflow's heading with its prose and one override item per overridden component, then each of its steps.
378    fn write_workflow(&mut self, workflow: &'a Workflow) {
379        let workflow_id = workflow.id();
380        let mut reasons = Vec::new();
381        let heading = Self::workflow_heading(workflow, &mut reasons);
382        let prose = match workflow.description() {
383            Some(prose) => {
384                let prose_spelling = self.spell_prose(prose, workflow);
385                let spelled_prose = Self::spelling_or_push_reason(prose_spelling, &mut reasons);
386                Some(spelled_prose)
387            }
388            None => None,
389        };
390        self.report(reasons, || {
391            let owner_id = workflow_id.clone();
392            UnprintableOwner::Workflow(owner_id)
393        });
394        let mut override_items = String::new();
395        for (path, element_override) in workflow.overrides() {
396            let mut reasons = Vec::new();
397            let item = self.override_item(path, element_override, &mut reasons);
398            override_items.push_str(&item);
399            self.report(reasons, || {
400                let owner_id = workflow_id.clone();
401                let overridden_path = path.clone();
402                UnprintableOwner::Override(owner_id, overridden_path)
403            });
404        }
405        self.write_headed_part(&heading, prose.as_deref(), &override_items);
406        let mut declarations = EdgeDeclarations::of(workflow);
407        for step in workflow.steps() {
408            self.write_step(workflow, step, &mut declarations);
409        }
410    }
411
412    /// Spell `### ID: TITLE [(subtitle=TEXT)]`, pushing onto `reasons` what the language cannot say.
413    fn workflow_heading(workflow: &Workflow, reasons: &mut Vec<UnprintableReason>) -> String {
414        let workflow_id = workflow.id();
415        let workflow_title = workflow.title();
416        let title_spelling = Self::spell_title(workflow_title);
417        let title = Self::spelling_or_push_reason(title_spelling, reasons);
418        let Some(subtitle) = workflow.subtitle() else {
419            return format!("### {workflow_id}: {title}");
420        };
421        let subtitle_spelling = Self::spell_value(subtitle, TextField::Subtitle);
422        let subtitle_value = Self::spelling_or_push_reason(subtitle_spelling, reasons);
423        format!("### {workflow_id}: {title} (subtitle={subtitle_value})")
424    }
425
426    /// Spell `- REF (PROPERTIES)`, the parentheses kept for an override that sets nothing, pushing onto `reasons` what the language cannot say.
427    fn override_item(
428        &self,
429        path: &ElementPath,
430        element_override: &ElementOverride,
431        reasons: &mut Vec<UnprintableReason>,
432    ) -> String {
433        let reference = self.references.spell(path);
434        let overridden_properties = element_override.properties();
435        let position = element_override.position();
436        let child_placement = element_override.child_placement();
437        let properties =
438            self.component_properties(overridden_properties, position, child_placement, reasons);
439        let entries = properties.join(", ");
440        format!("- {reference} ({entries})\n")
441    }
442
443    /// Write the step's heading with its prose, then one item per item it lists, an edge declared at its first listing that Markdown reads back with its ordinal.
444    fn write_step(
445        &mut self,
446        workflow: &'a Workflow,
447        step: &'a Step,
448        declarations: &mut EdgeDeclarations<'a>,
449    ) {
450        let workflow_id = workflow.id();
451        let step_id = step.id();
452        let mut reasons = Vec::new();
453        let step_title = step.title();
454        let title_spelling = Self::spell_title(step_title);
455        let title = Self::spelling_or_push_reason(title_spelling, &mut reasons);
456        let heading = format!("#### {step_id}: {title}");
457        let prose = match step.description() {
458            Some(prose) => {
459                let prose_spelling = self.spell_prose(prose, workflow);
460                let spelled_prose = Self::spelling_or_push_reason(prose_spelling, &mut reasons);
461                Some(spelled_prose)
462            }
463            None => None,
464        };
465        self.report(reasons, || {
466            let owner_workflow_id = workflow_id.clone();
467            let owner_step_id = step_id.clone();
468            UnprintableOwner::Step(owner_workflow_id, owner_step_id)
469        });
470        let mut items = String::new();
471        for item in step.items() {
472            let item_line = match item {
473                StepItem::Element(path) => {
474                    let reference = self.references.spell(path);
475                    format!("- {reference}\n")
476                }
477                StepItem::Edge(id) => self.edge_item(workflow, id, declarations),
478            };
479            items.push_str(&item_line);
480        }
481        self.write_headed_part(&heading, prose.as_deref(), &items);
482    }
483
484    /// Spell the edge `id` as a step item: a declaration with its label, properties and description where `EdgeDeclarations` declares it, `- FROM -> TO[n]` elsewhere, reporting what the language cannot say.
485    fn edge_item(
486        &mut self,
487        workflow: &'a Workflow,
488        id: &'a EdgeId,
489        declarations: &mut EdgeDeclarations<'a>,
490    ) -> String {
491        let from_path = id.from();
492        let to_path = id.to();
493        let from_reference = self.references.spell(from_path);
494        let to_reference = self.references.spell(to_path);
495        let edge = workflow.edge(id);
496        let reading_ordinal = declarations.declare(id);
497        let (Some(edge), Some(reading_ordinal)) = (edge, reading_ordinal) else {
498            let ordinal = id.ordinal();
499            return format!("- {from_reference} -> {to_reference}[{ordinal}]\n");
500        };
501        let mut reasons = Vec::new();
502        let ordinal = id.ordinal();
503        let is_renumbered = ordinal != reading_ordinal;
504        if is_renumbered {
505            let renumbered_edge = UnprintableReason::RenumberedEdge {
506                ordinal,
507                reading_ordinal,
508            };
509            reasons.push(renumbered_edge);
510        }
511        let label = match edge.label() {
512            Some(label) => {
513                let label_spelling = Self::spell_label(label, TextField::Label);
514                let label = Self::spelling_or_push_reason(label_spelling, &mut reasons);
515                format!(": {label}")
516            }
517            None => String::new(),
518        };
519        let properties = Self::edge_properties(edge, &mut reasons);
520        let properties_list = Self::spell_properties_list(&properties);
521        let item_line = format!("- {from_reference} -> {to_reference}{label}{properties_list}\n");
522        let spelled_item = match edge.description() {
523            Some(description) => {
524                let description_spelling = self.spell_description(description, "  ", workflow);
525                let spelled_description =
526                    Self::spelling_or_push_reason(description_spelling, &mut reasons);
527                format!("{item_line}{spelled_description}")
528            }
529            None => item_line,
530        };
531        self.report(reasons, || {
532            let owner_workflow_id = workflow.id().clone();
533            let edge_id = id.clone();
534            UnprintableOwner::Edge(owner_workflow_id, edge_id)
535        });
536        spelled_item
537    }
538
539    /// Spell the edge's properties in the language's table order, `line`, `color`, `traveler`, pushing onto `reasons` what the language cannot say.
540    fn edge_properties(edge: &Edge, reasons: &mut Vec<UnprintableReason>) -> Vec<String> {
541        let mut properties = Vec::new();
542        let style = edge.style();
543        let is_badge = edge.is_badge();
544        let stroke_dash = style.get(STROKE_DASH);
545        match (is_badge, stroke_dash) {
546            (true, Some(_stroke_dash)) => reasons.push(UnprintableReason::BadgeWithStrokeDash),
547            (true, None) => {
548                let badge: &str = LineStyle::Badge.into();
549                let property = format!("line={badge}");
550                properties.push(property);
551            }
552            (false, Some(stroke_dash)) => {
553                let line_spelling = Self::spell_line(stroke_dash);
554                let line = Self::spelling_or_push_reason(line_spelling, reasons);
555                let property = format!("line={line}");
556                properties.push(property);
557            }
558            (false, None) => {}
559        }
560        if let Some(stroke) = style.get(STROKE) {
561            let property = format!("color={stroke}");
562            properties.push(property);
563        }
564        if let Some(traveler) = edge.traveler() {
565            let traveler_keyword: &str = traveler.into();
566            let property = format!("traveler={traveler_keyword}");
567            properties.push(property);
568        }
569        let unmapped_style_keys = Self::find_unmapped_style_keys(style, &SAYABLE_EDGE_STYLE_KEYS);
570        reasons.extend(unmapped_style_keys);
571        properties
572    }
573
574    /// Write a heading after a blank line, then its prose, then its items after a blank line when prose precedes them.
575    fn write_headed_part(&mut self, heading: &str, prose: Option<&str>, items: &str) {
576        let heading_line = format!("\n{heading}\n");
577        self.markdown.push_str(&heading_line);
578        if let Some(prose) = prose {
579            let prose_lines = format!("{prose}\n");
580            self.markdown.push_str(&prose_lines);
581        }
582        let has_items = !items.is_empty();
583        let has_prose_before_items = prose.is_some() && has_items;
584        if has_prose_before_items {
585            self.markdown.push('\n');
586        }
587        self.markdown.push_str(items);
588    }
589
590    /// Hand out the written Markdown, or every unprintable part found while writing it.
591    fn finish(self) -> Result<String, UnprintableModel> {
592        let is_printable = self.unprintables.is_empty();
593        if is_printable {
594            return Ok(self.markdown);
595        }
596        let unprintables = self.unprintables;
597        let unprintable_model = UnprintableModel { unprintables };
598        Err(unprintable_model)
599    }
600
601    /// Record each reason as an unprintable part of `owner`, building the owner only when there is a reason.
602    fn report(&mut self, reasons: Vec<UnprintableReason>, owner: impl Fn() -> UnprintableOwner) {
603        let unprintables = reasons.into_iter().map(|reason| {
604            let owner = owner();
605            Unprintable { owner, reason }
606        });
607        self.unprintables.extend(unprintables);
608    }
609
610    /// Return the spelling, or push the reason there is none onto `reasons` and return an empty spelling, which an unprintable model never shows.
611    fn spelling_or_push_reason<T: Default>(
612        spelling: Result<T, UnprintableReason>,
613        reasons: &mut Vec<UnprintableReason>,
614    ) -> T {
615        spelling.unwrap_or_else(|reason| {
616            reasons.push(reason);
617            T::default()
618        })
619    }
620
621    /// Find every style key of `style` outside `sayable_keys`, the keys no language property lowers to.
622    fn find_unmapped_style_keys(
623        style: &BTreeMap<String, String>,
624        sayable_keys: &[&str],
625    ) -> impl Iterator<Item = UnprintableReason> {
626        let style_keys = style.keys();
627        let unmapped_keys = style_keys.filter(|key| {
628            let is_sayable = sayable_keys.contains(&key.as_str());
629            !is_sayable
630        });
631        unmapped_keys
632            .cloned()
633            .map(UnprintableReason::UnmappedStyleKey)
634    }
635
636    /// Spell a stroke-dash as the `line` keyword drawing it, or report a length no keyword draws.
637    fn spell_line(stroke_dash: &str) -> Result<&'static str, UnprintableReason> {
638        let dash_length = NumberValue::parse(stroke_dash);
639        let line_style = dash_length.and_then(LineStyle::drawing_dash);
640        let Some(line_style) = line_style else {
641            let unsayable_dash = stroke_dash.to_owned();
642            let unsayable_stroke_dash = UnprintableReason::UnsayableStrokeDash(unsayable_dash);
643            return Err(unsayable_stroke_dash);
644        };
645        let line_keyword: &'static str = line_style.into();
646        Ok(line_keyword)
647    }
648
649    /// Spell `text` as a value: bare when it reads back as itself, otherwise quoted.
650    fn spell_value(text: &str, field: TextField) -> Result<String, UnprintableReason> {
651        let reads_back_bare = Value::reads_back_bare(text);
652        Self::spell_bare_or_quoted(text, reads_back_bare, field)
653    }
654
655    /// Spell `text` as a label or display name: bare when it reads back as itself, otherwise quoted.
656    fn spell_label(text: &str, field: TextField) -> Result<String, UnprintableReason> {
657        let reads_back_bare = ItemLabel::reads_back_bare(text);
658        Self::spell_bare_or_quoted(text, reads_back_bare, field)
659    }
660
661    /// Spell `text` as a heading's title: bare when it reads back as a label and ends with no `#`, which Markdown reads as the heading's closing `#`s, otherwise quoted.
662    fn spell_title(text: &str) -> Result<String, UnprintableReason> {
663        let reads_back_as_label = ItemLabel::reads_back_bare(text);
664        let is_ending_with_hash = text.ends_with('#');
665        let reads_back_bare = reads_back_as_label && !is_ending_with_hash;
666        Self::spell_bare_or_quoted(text, reads_back_bare, TextField::Title)
667    }
668
669    /// Spell `text` bare when `reads_back_bare`, otherwise quoted, or report text no item line keeps.
670    fn spell_bare_or_quoted(
671        text: &str,
672        reads_back_bare: bool,
673        field: TextField,
674    ) -> Result<String, UnprintableReason> {
675        let one_line_problem = Self::check_one_line_text(text, field);
676        if let Some(one_line_problem) = one_line_problem {
677            return Err(one_line_problem);
678        }
679        if reads_back_bare {
680            let bare_text = text.to_owned();
681            return Ok(bare_text);
682        }
683        let backslash_escaped = text.replace('\\', "\\\\");
684        let escaped = backslash_escaped.replace('"', "\\\"");
685        let quoted = format!("\"{escaped}\"");
686        Ok(quoted)
687    }
688
689    /// Check that `text` survives inside one item line: no line break ends it and no comment opening drops it.
690    fn check_one_line_text(text: &str, field: TextField) -> Option<UnprintableReason> {
691        let has_line_break = text.contains(LINE_BREAKS);
692        if has_line_break {
693            let text = text.to_owned();
694            let line_break = UnprintableReason::LineBreak { field, text };
695            return Some(line_break);
696        }
697        let has_comment_opening = text.contains(COMMENT_OPENING);
698        if has_comment_opening {
699            let text = text.to_owned();
700            let comment_opening = UnprintableReason::CommentOpening { field, text };
701            return Some(comment_opening);
702        }
703        None
704    }
705
706    /// 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.
707    fn spell_description(
708        &self,
709        description: &str,
710        indent: &str,
711        numbering: &(impl EdgeNumbering + ?Sized),
712    ) -> Result<String, UnprintableReason> {
713        let respelled_description = self.respell_targets(description, numbering);
714        let description = respelled_description.as_str();
715        let item_spelling = Self::fenced_description(description, "  ");
716        let reads_back = Skeleton::reads_back_as_description(description, &item_spelling);
717        let is_lossy = !reads_back;
718        if is_lossy {
719            return Err(UnprintableReason::UnreadableDescription);
720        }
721        let spelled_description = Self::fenced_description(description, indent);
722        Ok(spelled_description)
723    }
724
725    /// Spell `prose` to be written verbatim under a heading of `workflow`, its targets respelled as references, or report prose that would not read back verbatim there.
726    fn spell_prose(&self, prose: &str, workflow: &Workflow) -> Result<String, UnprintableReason> {
727        let respelled_prose = self.respell_targets(prose, workflow);
728        let reads_back = Skeleton::reads_back_as_prose(&respelled_prose);
729        let is_lossy = !reads_back;
730        if is_lossy {
731            return Err(UnprintableReason::UnreadableProse);
732        }
733        Ok(respelled_prose)
734    }
735
736    /// Respell each canonical `#` target of `description` by its shortest unique references, `[n]` dropped where `numbering` adds it back on reading, so an unnumbered target it numbers reads back numbered.
737    fn respell_targets(
738        &self,
739        description: &str,
740        numbering: &(impl EdgeNumbering + ?Sized),
741    ) -> String {
742        TargetLink::respell_all(description, |target_link| {
743            let description_target = DescriptionTarget::parse(target_link.target)?;
744            match description_target {
745                DescriptionTarget::Element(path) => {
746                    let reference = self.references.spell(&path);
747                    Some(reference)
748                }
749                DescriptionTarget::Edge(id) => {
750                    let from_reference = self.references.spell(id.from());
751                    let to_reference = self.references.spell(id.to());
752                    let ordinal = id.ordinal();
753                    let reading_ordinal = numbering.number_unnumbered(id.from(), id.to());
754                    let is_numbered_on_reading = reading_ordinal == Some(ordinal);
755                    if is_numbered_on_reading {
756                        let unnumbered_target = format!("{from_reference}->{to_reference}");
757                        return Some(unnumbered_target);
758                    }
759                    let numbered_target = format!("{from_reference}->{to_reference}[{ordinal}]");
760                    Some(numbered_target)
761                }
762                DescriptionTarget::UnnumberedEdge { from, to } => {
763                    let from_reference = self.references.spell(&from);
764                    let to_reference = self.references.spell(&to);
765                    let unnumbered_target = format!("{from_reference}->{to_reference}");
766                    Some(unnumbered_target)
767                }
768                DescriptionTarget::Step { .. } => None,
769            }
770        })
771    }
772
773    /// Fence `description` as a description block after `indent`; fence length = max(3, 1 + longest backtick run opening a line).
774    fn fenced_description(description: &str, indent: &str) -> String {
775        let opening_backtick_runs = description.split('\n').map(|line| {
776            let after_spaces = line.trim_start_matches(' ');
777            let after_backticks = after_spaces.trim_start_matches('`');
778            after_spaces.len() - after_backticks.len()
779        });
780        let longest_opening_run = opening_backtick_runs.max().unwrap_or_default();
781        let fence_length = MIN_FENCE_LENGTH.max(longest_opening_run + 1);
782        let fence = "`".repeat(fence_length);
783        let content = Self::indented_lines(description, indent);
784        format!("{indent}{fence}{DESCRIPTION_INFO}\n{content}{indent}{fence}\n")
785    }
786
787    /// Spell each line of `text` after `indent`, an empty line left empty, each ending with a line break.
788    fn indented_lines(text: &str, indent: &str) -> String {
789        let lines = text.split('\n').map(|line| {
790            let is_empty_line = line.is_empty();
791            if is_empty_line {
792                return "\n".to_owned();
793            }
794            format!("{indent}{line}\n")
795        });
796        lines.collect()
797    }
798
799    /// Spell a properties list as ` (a, b)`, or as nothing when there is no property.
800    fn spell_properties_list(properties: &[String]) -> String {
801        let is_empty = properties.is_empty();
802        if is_empty {
803            return String::new();
804        }
805        let entries = properties.join(", ");
806        format!(" ({entries})")
807    }
808}
809
810impl<'a> EdgeDeclarations<'a> {
811    /// Create new declarations of `workflow`, none printed yet, counting every listing of each of its edges.
812    fn of(workflow: &'a Workflow) -> Self {
813        let mut listings_left: BTreeMap<&'a EdgeId, usize> = BTreeMap::new();
814        let items = workflow.steps().iter().flat_map(Step::items);
815        for item in items {
816            let StepItem::Edge(id) = item else {
817                continue;
818            };
819            let listing_count = listings_left.entry(id).or_default();
820            *listing_count += 1;
821        }
822        Self {
823            declared_edges: BTreeSet::new(),
824            declaration_counts: BTreeMap::new(),
825            listings_left,
826        }
827    }
828
829    /// Declare the edge `id` at the first listing reached once every lower ordinal of its pair is declared, or at its last listing, returning the ordinal Markdown reads that declaration back with; `None` while it stays a reference.
830    fn declare(&mut self, id: &'a EdgeId) -> Option<u32> {
831        let listing_count = self.listings_left.entry(id).or_default();
832        *listing_count = listing_count.saturating_sub(1);
833        let has_later_listing = *listing_count > 0;
834        let is_already_declared = self.declared_edges.contains(id);
835        if is_already_declared {
836            return None;
837        }
838        let pair = (id.from(), id.to());
839        let declaration_count = self.declaration_counts.entry(pair).or_default();
840        let reading_ordinal = *declaration_count;
841        let is_declared_out_of_order = id.ordinal() != reading_ordinal;
842        let is_declaration_deferred = is_declared_out_of_order && has_later_listing;
843        if is_declaration_deferred {
844            return None;
845        }
846        *declaration_count += 1;
847        self.declared_edges.insert(id);
848        Some(reading_ordinal)
849    }
850}
851
852/// Count the elements each dotted suffix of a path matches, to spell every element by its shortest unique reference.
853struct ReferenceIndex<'a> {
854    element_counts_by_suffix: HashMap<&'a [String], usize>,
855}
856
857impl<'a> ReferenceIndex<'a> {
858    /// Index every dotted suffix of every element path of `model`.
859    fn new(model: &'a Model) -> Self {
860        let mut element_counts_by_suffix = HashMap::new();
861        let elements = model.elements();
862        for path in elements.keys() {
863            let segments = path.segments();
864            let depth = segments.len();
865            let suffix_starts = 0..depth;
866            for suffix_start in suffix_starts {
867                let suffix = &segments[suffix_start..];
868                let element_count = element_counts_by_suffix.entry(suffix).or_default();
869                *element_count += 1;
870            }
871        }
872        Self {
873            element_counts_by_suffix,
874        }
875    }
876
877    /// Spell the shortest dotted suffix of `path` that ends no other element's path, else the whole path, which always names it exactly.
878    fn spell(&self, path: &ElementPath) -> String {
879        let segments = path.segments();
880        let depth = segments.len();
881        let suffix_starts = 0..depth;
882        let shortest_first_starts = suffix_starts.rev();
883        let suffixes = shortest_first_starts.map(|suffix_start| &segments[suffix_start..]);
884        let mut unique_suffixes = suffixes.filter(|suffix| {
885            let element_count = self.element_counts_by_suffix.get(suffix);
886            element_count == Some(&1)
887        });
888        let unique_suffix = unique_suffixes.next();
889        let reference_segments = unique_suffix.unwrap_or(segments);
890        reference_segments.join(".")
891    }
892}