1use 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
19const FILL: &str = ColorKey::FILL.name();
21const STROKE: &str = ColorKey::STROKE.name();
23const STROKE_DASH: &str = NumberKey::STROKE_DASH.name();
25const SAYABLE_ELEMENT_STYLE_KEYS: [&str; 3] = [FILL, STROKE, STROKE_DASH];
27const SAYABLE_EDGE_STYLE_KEYS: [&str; 2] = [STROKE, STROKE_DASH];
29const LINE_BREAKS: [char; 2] = ['\n', '\r'];
31const COMMENT_OPENING: &str = "<!--";
33const BLOCK_MARKERS: [char; 10] = ['#', '>', '-', '+', '*', '_', '`', '~', '<', '['];
35const MIN_FENCE_LENGTH: usize = 3;
37const ORDERED_LIST_DELIMITERS: [char; 2] = ['.', ')'];
39
40#[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 fn bulleted(unprintables: &[Unprintable]) -> String {
54 let lines = unprintables
55 .iter()
56 .map(|unprintable| format!("\n- {unprintable}"));
57 lines.collect()
58 }
59}
60
61#[derive(Clone, Debug, PartialEq, Error)]
63#[error("{owner}: {reason}")]
64pub struct Unprintable {
65 pub owner: UnprintableOwner,
66 pub reason: UnprintableReason,
67}
68
69#[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#[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#[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
167pub 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
889struct ReferenceIndex<'a> {
891 model: &'a Model,
892 element_counts_by_suffix: HashMap<&'a [String], usize>,
893}
894
895impl<'a> ReferenceIndex<'a> {
896 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 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}