use std::fmt;
use std::ops::Range;
use crate::model::EdgeId;
use crate::path::ElementPath;
use crate::workflow::{StepId, WorkflowId};
const EDGE_ARROW: &str = "->";
const FENCE: &str = "```";
const TARGET_MARKER: char = '#';
const STEP_SEPARATOR: char = '/';
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum DescriptionOwner {
Element(ElementPath),
Workflow(WorkflowId),
Step(WorkflowId, StepId),
Edge(WorkflowId, EdgeId),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum DescriptionTarget {
Element(ElementPath),
Edge(EdgeId),
UnnumberedEdge { from: ElementPath, to: ElementPath },
Step { workflow: WorkflowId, step: StepId },
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TargetLink<'a> {
pub target: &'a str,
pub range: Range<usize>,
}
pub struct CodeFence;
enum DescriptionLine<'a> {
Fence { info: &'a str, is_opening: bool },
FencedCode,
InlineText { 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 numbered_edge = Self::parse_numbered_edge(target);
let step = || Self::parse_step(target);
let unnumbered_edge = || Self::parse_unnumbered_edge(target);
let element = || Self::parse_element(target);
numbered_edge
.or_else(step)
.or_else(unnumbered_edge)
.or_else(element)
}
fn parse_numbered_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()?;
let (from, to) = Self::split_endpoints(endpoints)?;
let id = EdgeId::new(from, to, ordinal);
let edge_target = Self::Edge(id);
Some(edge_target)
}
fn parse_step(target: &str) -> Option<Self> {
let (workflow_text, step_text) = target.split_once(STEP_SEPARATOR)?;
let workflow = WorkflowId::new(workflow_text);
let step = StepId::new(step_text);
let is_either_id_malformed = !workflow.is_well_formed() || !step.is_well_formed();
if is_either_id_malformed {
return None;
}
let step_target = Self::Step { workflow, step };
Some(step_target)
}
fn parse_unnumbered_edge(target: &str) -> Option<Self> {
let (from, to) = Self::split_endpoints(target)?;
let unnumbered_edge_target = Self::UnnumberedEdge { from, to };
Some(unnumbered_edge_target)
}
fn parse_element(target: &str) -> Option<Self> {
let path = target.parse().ok()?;
let element_target = Self::Element(path);
Some(element_target)
}
fn split_endpoints(endpoints: &str) -> Option<(ElementPath, ElementPath)> {
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;
};
return Some((from, to));
}
None
}
}
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}]")
}
Self::UnnumberedEdge { from, to } => write!(formatter, "{from}{EDGE_ARROW}{to}"),
Self::Step { workflow, step } => write!(formatter, "{workflow}{STEP_SEPARATOR}{step}"),
}
}
}
impl fmt::Display for DescriptionOwner {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Element(path) => write!(formatter, "element `{path}`"),
Self::Workflow(workflow) => write!(formatter, "workflow `{workflow}`"),
Self::Step(workflow, step) => write!(formatter, "step `{workflow}/{step}`"),
Self::Edge(workflow, id) => write!(formatter, "edge `{id}` of workflow `{workflow}`"),
}
}
}
impl<'a> TargetLink<'a> {
pub fn find_all(description: &'a str) -> Vec<Self> {
let mut target_links = Vec::new();
for line in DescriptionLine::read_all(description) {
let DescriptionLine::InlineText { range } = line else {
continue;
};
Self::find_in_inline_text(description, range, &mut target_links);
}
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 CodeFence {
pub fn languages_in(description: &str) -> impl Iterator<Item = &str> {
let lines = DescriptionLine::read_all(description);
lines.filter_map(|line| {
let DescriptionLine::Fence {
info,
is_opening: true,
} = line
else {
return None;
};
info.split_whitespace().next()
})
}
}
impl<'a> DescriptionLine<'a> {
fn read_all(description: &'a str) -> impl Iterator<Item = Self> {
let mut is_in_fenced_code = false;
let mut line_start = 0;
description.split('\n').map(move |line| {
let line_end = line_start + line.len();
let range = line_start..line_end;
line_start = line_end + 1;
let trimmed_line = line.trim();
if let Some(info) = trimmed_line.strip_prefix(FENCE) {
let is_opening = !is_in_fenced_code;
is_in_fenced_code = is_opening;
return Self::Fence { info, is_opening };
}
if is_in_fenced_code {
return Self::FencedCode;
}
Self::InlineText { range }
})
}
}
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 only_an_opening_fence_names_a_language_and_its_first_word_is_it() {
let description = "An orchestrator:\n```rs\nstruct Shop { orders: Orders }\n```\n ``` sql title\nSELECT 1;\n ```\n```\nplain\n```";
let languages: Vec<&str> = CodeFence::languages_in(description).collect();
assert_eq!(
languages,
["rs", "sql"],
"closing fences and a bare opening fence name no language"
);
}
#[test]
fn a_canonical_target_reads_back_as_itself() {
let canonical_targets = [
"backend.api",
"backend.api->backend.queue[0]",
"a->b[12]",
"a->b",
"checkout/charge",
];
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_a_url = DescriptionTarget::parse("docs/guide.html");
let expected_element = ElementPath::from_segments(["docs/guide", "html"]);
assert_eq!(
element_named_like_a_url,
Some(DescriptionTarget::Element(expected_element)),
"a step target joins two well-formed ids; anything else with a `/` is a path"
);
}
}