use std::fmt;
use std::ops::Range;
use crate::model::{EdgeId, Model};
use crate::path::ElementPath;
const EDGE_ARROW: &str = "->";
const FENCE: &str = "```";
const TARGET_MARKER: char = '#';
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum DescriptionOwner {
Diagram,
Element(ElementPath),
Edge(EdgeId),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum DescriptionTarget {
Element(ElementPath),
Edge(EdgeId),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TargetLink<'a> {
pub target: &'a str,
pub range: Range<usize>,
}
enum InlineMatch {
CodeSpan {
length: usize,
},
Emphasis {
content: Range<usize>,
length: usize,
},
Link {
destination: Range<usize>,
length: usize,
},
}
impl DescriptionTarget {
pub fn parse(target: &str) -> Option<Self> {
let edge_target = Self::parse_edge(target);
if edge_target.is_some() {
return edge_target;
}
let path = target.parse().ok()?;
let element_target = Self::Element(path);
Some(element_target)
}
fn parse_edge(target: &str) -> Option<Self> {
let endpoints_and_ordinal = target.strip_suffix(']')?;
let (endpoints, ordinal_text) = endpoints_and_ordinal.rsplit_once('[')?;
let has_non_digit = ordinal_text.bytes().any(|byte| !byte.is_ascii_digit());
let is_unnumbered = ordinal_text.is_empty() || has_non_digit;
if is_unnumbered {
return None;
}
let ordinal = ordinal_text.parse().ok()?;
for (arrow_start, _arrow) in endpoints.match_indices(EDGE_ARROW) {
let from_text = &endpoints[..arrow_start];
let to_start = arrow_start + EDGE_ARROW.len();
let to_text = &endpoints[to_start..];
let (Ok(from), Ok(to)) = (from_text.parse(), to_text.parse()) else {
continue;
};
let id = EdgeId::new(from, to, ordinal);
let edge_target = Self::Edge(id);
return Some(edge_target);
}
None
}
pub fn is_in(&self, model: &Model) -> bool {
match self {
Self::Element(path) => model.element(path).is_some(),
Self::Edge(id) => model.edge(id).is_some(),
}
}
}
impl fmt::Display for DescriptionTarget {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Element(path) => write!(formatter, "{path}"),
Self::Edge(id) => {
let from = id.from();
let to = id.to();
let ordinal = id.ordinal();
write!(formatter, "{from}{EDGE_ARROW}{to}[{ordinal}]")
}
}
}
}
impl fmt::Display for DescriptionOwner {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Diagram => formatter.write_str("the diagram"),
Self::Element(path) => write!(formatter, "element `{path}`"),
Self::Edge(id) => write!(formatter, "edge `{id}`"),
}
}
}
impl<'a> TargetLink<'a> {
pub fn find_all(description: &'a str) -> Vec<Self> {
let mut target_links = Vec::new();
let mut is_in_fenced_code = false;
let mut line_start = 0;
for line in description.split('\n') {
let line_end = line_start + line.len();
let is_fence = line.trim().starts_with(FENCE);
if is_fence {
is_in_fenced_code = !is_in_fenced_code;
}
let is_inline_text = !is_fence && !is_in_fenced_code;
if is_inline_text {
let line_range = line_start..line_end;
Self::find_in_inline_text(description, line_range, &mut target_links);
}
line_start = line_end + 1;
}
target_links
}
pub fn respell_all(
description: &str,
mut respell: impl FnMut(&TargetLink<'_>) -> Option<String>,
) -> String {
let mut respelled = String::with_capacity(description.len());
let mut copied_end = 0;
for target_link in TargetLink::find_all(description) {
let Some(respelled_target) = respell(&target_link) else {
continue;
};
let target_range = target_link.range;
let text_before_target = &description[copied_end..target_range.start];
respelled.push_str(text_before_target);
respelled.push_str(&respelled_target);
copied_end = target_range.end;
}
let text_after_last_target = &description[copied_end..];
respelled.push_str(text_after_last_target);
respelled
}
fn find_in_inline_text(
description: &'a str,
range: Range<usize>,
target_links: &mut Vec<Self>,
) {
let mut index = range.start;
while index < range.end {
let rest = &description[index..range.end];
let inline_match = InlineMatch::at_start_of(rest);
match inline_match {
Some(InlineMatch::CodeSpan { length }) => index += length,
Some(InlineMatch::Emphasis { content, length }) => {
let content_range = index + content.start..index + content.end;
Self::find_in_inline_text(description, content_range, target_links);
index += length;
}
Some(InlineMatch::Link {
destination,
length,
}) => {
let destination_text = &rest[destination.clone()];
let is_target = destination_text.starts_with(TARGET_MARKER);
if is_target {
let target_start = index + destination.start + TARGET_MARKER.len_utf8();
let target_end = index + destination.end;
let target = &description[target_start..target_end];
let range = target_start..target_end;
let target_link = Self { target, range };
target_links.push(target_link);
}
index += length;
}
None => {
let first_character = rest.chars().next();
index += first_character.map_or(1, char::len_utf8);
}
}
}
}
}
impl InlineMatch {
fn at_start_of(text: &str) -> Option<Self> {
let code_span = Self::code_span(text);
let bold = || Self::bold(text);
let emphasis = || Self::emphasis(text);
let link = || Self::link(text);
code_span.or_else(bold).or_else(emphasis).or_else(link)
}
fn code_span(text: &str) -> Option<Self> {
let after_opening = text.strip_prefix('`')?;
let code_length = after_opening.find('`')?;
let is_empty_code = code_length == 0;
if is_empty_code {
return None;
}
let length = code_length + 2;
Some(Self::CodeSpan { length })
}
fn bold(text: &str) -> Option<Self> {
let after_opening = text.strip_prefix("**")?;
let first_character = after_opening.chars().next()?;
let first_length = first_character.len_utf8();
let after_first_character = &after_opening[first_length..];
let closing_offset = after_first_character.find("**")?;
let content_end = 2 + first_length + closing_offset;
let content = 2..content_end;
let length = content_end + 2;
Some(Self::Emphasis { content, length })
}
fn emphasis(text: &str) -> Option<Self> {
let after_opening = text.strip_prefix('*')?;
let first_character = after_opening.chars().next()?;
let is_invalid_start = first_character == '*' || first_character.is_whitespace();
if is_invalid_start {
return None;
}
let closing_offset = after_opening.find('*')?;
let content_end = 1 + closing_offset;
let content = 1..content_end;
let length = content_end + 1;
Some(Self::Emphasis { content, length })
}
fn link(text: &str) -> Option<Self> {
let after_opening = text.strip_prefix('[')?;
let text_length = after_opening.find(']')?;
let is_empty_text = text_length == 0;
if is_empty_text {
return None;
}
let parenthesis_start = 1 + text_length + 1;
let after_text = &text[parenthesis_start..];
let after_parenthesis = after_text.strip_prefix('(')?;
let destination_length = after_parenthesis.find(|character: char| {
let is_parenthesis = matches!(character, '(' | ')');
is_parenthesis || character.is_whitespace()
})?;
let after_destination = &after_parenthesis[destination_length..];
let is_unclosed = !after_destination.starts_with(')');
let is_empty_destination = destination_length == 0;
if is_unclosed || is_empty_destination {
return None;
}
let destination_start = parenthesis_start + 1;
let destination_end = destination_start + destination_length;
let destination = destination_start..destination_end;
let length = destination_end + 1;
Some(Self::Link {
destination,
length,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_links_the_runtime_reads_are_target_links() {
let description = "See [the API](#backend.api), **[the job](#api->queue[1])** and [docs](https://x.y).\n`[code](#no)` stays code.\n```\n[fenced](#no)\n```\n[empty]() [spaced](#a b)";
let target_links = TargetLink::find_all(description);
let targets: Vec<&str> = target_links.iter().map(|link| link.target).collect();
assert_eq!(
targets,
["backend.api", "api->queue[1]"],
"a target link is a `[TEXT](#TARGET)` outside code, as the runtime renders it"
);
let respelled = TargetLink::respell_all(description, |link| {
let respelled_target = link.target.to_uppercase();
Some(respelled_target)
});
let is_rest_unchanged =
respelled.ends_with("[spaced](#a b)") && respelled.contains("[code](#no)");
assert!(
respelled.starts_with("See [the API](#BACKEND.API), **[the job](#API->QUEUE[1])**")
);
assert!(
is_rest_unchanged,
"respelling touches targets only: {respelled}"
);
}
#[test]
fn a_canonical_target_reads_back_as_itself() {
let canonical_targets = ["backend.api", "backend.api->backend.queue[0]", "a->b[12]"];
for canonical_target in canonical_targets {
let target = DescriptionTarget::parse(canonical_target)
.unwrap_or_else(|| panic!("{canonical_target} is canonical"));
let respelled = target.to_string();
assert_eq!(
respelled, canonical_target,
"parse then display is the identity on canonical targets"
);
}
let element_named_like_an_unnumbered_edge = DescriptionTarget::parse("a->b");
let expected_element = ElementPath::from_segments(["a->b"]);
assert_eq!(
element_named_like_an_unnumbered_edge,
Some(DescriptionTarget::Element(expected_element)),
"an edge target always carries `[n]`; without it the text is a path"
);
}
}