use crate::{MermaidConfig, SourceSpan};
use super::{
NODE_TYPE_BANG, NODE_TYPE_CIRCLE, NODE_TYPE_CLOUD, NODE_TYPE_DEFAULT, NODE_TYPE_HEXAGON,
NODE_TYPE_RECT, NODE_TYPE_ROUNDED_RECT,
};
pub(super) fn strip_inline_comment(line: &str) -> &str {
let mut in_quote = false;
let mut in_backtick_quote = false;
let mut it = line.char_indices().peekable();
while let Some((idx, ch)) = it.next() {
if in_backtick_quote {
if ch == '`' && it.peek().is_some_and(|(_, next)| *next == '"') {
in_backtick_quote = false;
it.next();
}
continue;
}
if in_quote {
if ch == '"' {
in_quote = false;
}
continue;
}
if ch == '"' {
if it.peek().is_some_and(|(_, next)| *next == '`') {
in_backtick_quote = true;
it.next();
continue;
}
in_quote = true;
continue;
}
if ch == '%' && it.peek().is_some_and(|(_, next)| *next == '%') {
return &line[..idx];
}
}
line
}
pub(super) struct NodeSpec {
pub id_raw: String,
pub descr_raw: String,
pub ty: i32,
pub descr_is_markdown: bool,
pub trace: NodeSpecTrace,
}
#[derive(Debug, Clone, Default)]
pub(super) struct NodeSpecTrace {
pub id_span: Option<SourceSpan>,
pub description_span: Option<SourceSpan>,
pub shape_opening: Option<SourceSpan>,
pub shape_closing: Option<SourceSpan>,
pub text_opening: Option<SourceSpan>,
pub text_closing: Option<SourceSpan>,
pub explicit_id: bool,
}
pub(super) struct NodeSpecError {
pub message: String,
pub trace: NodeSpecTrace,
pub continuation: Option<NodeSpecContinuation>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum NodeSpecContinuation {
AwaitingClosingDelimiter {
expected: &'static str,
text: Option<NodeTextContinuation>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum NodeTextContinuation {
Quoted,
QuotedMarkdown,
BareMarkdown,
}
impl NodeSpecContinuation {
pub fn expected_closing(self) -> &'static str {
match self {
Self::AwaitingClosingDelimiter { expected, .. } => expected,
}
}
pub fn has_open_text(self) -> bool {
match self {
Self::AwaitingClosingDelimiter { text, .. } => text.is_some(),
}
}
}
pub(super) fn parse_node_spec(input: &str) -> std::result::Result<NodeSpec, Box<NodeSpecError>> {
let input = input.trim_end();
if input.is_empty() {
return Err(node_spec_error(
"expected node",
NodeSpecTrace::default(),
None,
));
}
if let Some((start, end)) = node_delimiter_pair_at_start(input) {
let opening = SourceSpan::new(0, start.len());
let (inner, tail, closing_start) =
extract_delimited(input, start, end).map_err(|failure| {
let text_opening = text_opening_span(&input[start.len()..], start.len());
node_spec_error(
failure.message(),
NodeSpecTrace {
shape_opening: Some(opening),
text_opening,
..NodeSpecTrace::default()
},
failure.continuation(),
)
})?;
let closing = SourceSpan::new(closing_start, closing_start + end.len());
let parsed_text = parse_node_text(inner, start.len());
let trace = NodeSpecTrace {
id_span: Some(parsed_text.content),
description_span: None,
shape_opening: Some(opening),
shape_closing: Some(closing),
text_opening: parsed_text.opening,
text_closing: parsed_text.closing,
explicit_id: false,
};
if !tail.trim().is_empty() {
return Err(node_spec_error("unexpected trailing input", trace, None));
}
let ty = node_type_for(start, end);
return Ok(NodeSpec {
id_raw: parsed_text.value.clone(),
descr_raw: parsed_text.value,
ty,
descr_is_markdown: parsed_text.is_markdown,
trace,
});
}
let (id_raw, rest) = split_node_id(input);
let id_raw = id_raw.to_string();
let rest = rest.trim_end();
if rest.is_empty() {
let id_end = id_raw.trim_end().len();
return Ok(NodeSpec {
id_raw: id_raw.clone(),
descr_raw: id_raw,
ty: NODE_TYPE_DEFAULT,
descr_is_markdown: false,
trace: NodeSpecTrace {
id_span: Some(SourceSpan::new(0, id_end)),
explicit_id: true,
..NodeSpecTrace::default()
},
});
}
let Some((start, end)) = node_delimiter_pair_at_start(rest) else {
let id_end = id_raw.trim_end().len();
return Err(node_spec_error(
"expected node delimiter",
NodeSpecTrace {
id_span: Some(SourceSpan::new(0, id_end)),
explicit_id: true,
..NodeSpecTrace::default()
},
None,
));
};
let rest_start = input.len() - rest.len();
let opening = SourceSpan::new(rest_start, rest_start + start.len());
let (inner, tail, closing_start_in_rest) =
extract_delimited(rest, start, end).map_err(|failure| {
let text_opening = text_opening_span(&rest[start.len()..], rest_start + start.len());
node_spec_error(
failure.message(),
NodeSpecTrace {
id_span: Some(SourceSpan::new(0, id_raw.trim_end().len())),
shape_opening: Some(opening),
text_opening,
explicit_id: true,
..NodeSpecTrace::default()
},
failure.continuation(),
)
})?;
let closing_start = rest_start + closing_start_in_rest;
let closing = SourceSpan::new(closing_start, closing_start + end.len());
let parsed_text = parse_node_text(inner, rest_start + start.len());
let trace = NodeSpecTrace {
id_span: Some(SourceSpan::new(0, input[..rest_start].trim_end().len())),
description_span: Some(parsed_text.content),
shape_opening: Some(opening),
shape_closing: Some(closing),
text_opening: parsed_text.opening,
text_closing: parsed_text.closing,
explicit_id: true,
};
if !tail.trim().is_empty() {
return Err(node_spec_error("unexpected trailing input", trace, None));
}
let ty = node_type_for(start, end);
Ok(NodeSpec {
id_raw,
descr_raw: parsed_text.value,
ty,
descr_is_markdown: parsed_text.is_markdown,
trace,
})
}
pub(super) fn starts_node_spec(input: &str) -> bool {
let input = input.trim_end();
if input.is_empty() || node_delimiter_pair_at_start(input).is_some() {
return !input.is_empty();
}
let (id, _) = split_node_id(input);
!id.trim_end().is_empty()
}
fn node_spec_error(
message: impl Into<String>,
trace: NodeSpecTrace,
continuation: Option<NodeSpecContinuation>,
) -> Box<NodeSpecError> {
Box::new(NodeSpecError {
message: message.into(),
trace,
continuation,
})
}
fn split_node_id(input: &str) -> (&str, &str) {
let bytes = input.as_bytes();
for (idx, b) in bytes.iter().enumerate() {
match b {
b'(' | b')' | b'[' | b'{' | b'}' => return (&input[..idx], &input[idx..]),
_ => {}
}
}
(input, "")
}
fn node_delimiter_pair_at_start(input: &str) -> Option<(&'static str, &'static str)> {
let pairs: &[(&str, &str)] = &[
("(-", "-)"),
("-)", "(-"),
("((", "))"),
("))", "(("),
("{{", "}}"),
("[", "]"),
(")", "("),
("(", ")"),
];
for (start, end) in pairs {
if input.starts_with(start) {
return Some((*start, *end));
}
}
None
}
enum DelimitedScanFailure {
ExpectedOpening,
AwaitingClosingDelimiter {
expected: &'static str,
text: Option<NodeTextContinuation>,
},
}
impl DelimitedScanFailure {
fn message(&self) -> &'static str {
match self {
Self::ExpectedOpening => "expected delimiter start",
Self::AwaitingClosingDelimiter { .. } => "unterminated node delimiter",
}
}
fn continuation(&self) -> Option<NodeSpecContinuation> {
match *self {
Self::ExpectedOpening => None,
Self::AwaitingClosingDelimiter { expected, text } => {
Some(NodeSpecContinuation::AwaitingClosingDelimiter { expected, text })
}
}
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum NodeTextScanState {
Plain,
Quoted,
QuotedMarkdown,
BareMarkdown,
}
impl NodeTextScanState {
fn continuation(self) -> Option<NodeTextContinuation> {
match self {
Self::Plain => None,
Self::Quoted => Some(NodeTextContinuation::Quoted),
Self::QuotedMarkdown => Some(NodeTextContinuation::QuotedMarkdown),
Self::BareMarkdown => Some(NodeTextContinuation::BareMarkdown),
}
}
}
fn extract_delimited<'a>(
input: &'a str,
start: &'static str,
end: &'static str,
) -> std::result::Result<(&'a str, &'a str, usize), DelimitedScanFailure> {
if !input.starts_with(start) {
return Err(DelimitedScanFailure::ExpectedOpening);
}
let mut text_state = NodeTextScanState::Plain;
let start_len = start.len();
let mut it = input[start_len..].char_indices().peekable();
while let Some((off, ch)) = it.next() {
let idx = start_len + off;
match text_state {
NodeTextScanState::QuotedMarkdown => {
if ch == '`' && it.peek().is_some_and(|(_, next)| *next == '"') {
text_state = NodeTextScanState::Plain;
it.next();
}
continue;
}
NodeTextScanState::Quoted => {
if ch == '"' {
text_state = NodeTextScanState::Plain;
}
continue;
}
NodeTextScanState::BareMarkdown => {
if ch == '`' {
text_state = NodeTextScanState::Plain;
continue;
}
if input[idx..].starts_with(end) {
let inner = &input[start_len..idx];
let tail = &input[idx + end.len()..];
return Ok((inner, tail, idx));
}
continue;
}
NodeTextScanState::Plain => {}
}
match ch {
'"' if it.peek().is_some_and(|(_, next)| *next == '`') => {
text_state = NodeTextScanState::QuotedMarkdown;
it.next();
continue;
}
'"' => {
text_state = NodeTextScanState::Quoted;
continue;
}
'`' => {
text_state = NodeTextScanState::BareMarkdown;
continue;
}
_ => {}
}
if input[idx..].starts_with(end) {
let inner = &input[start_len..idx];
let tail = &input[idx + end.len()..];
return Ok((inner, tail, idx));
}
}
Err(DelimitedScanFailure::AwaitingClosingDelimiter {
expected: end,
text: text_state.continuation(),
})
}
struct ParsedNodeText {
value: String,
content: SourceSpan,
opening: Option<SourceSpan>,
closing: Option<SourceSpan>,
is_markdown: bool,
}
fn parse_node_text(raw: &str, raw_start: usize) -> ParsedNodeText {
if let Some(inner) = raw.strip_prefix("\"`").and_then(|s| s.strip_suffix("`\"")) {
return ParsedNodeText {
value: inner.to_string(),
content: SourceSpan::new(raw_start + 2, raw_start + raw.len() - 2),
opening: Some(SourceSpan::new(raw_start, raw_start + 2)),
closing: Some(SourceSpan::new(
raw_start + raw.len() - 2,
raw_start + raw.len(),
)),
is_markdown: true,
};
}
if let Some(inner) = raw.strip_prefix('\"').and_then(|s| s.strip_suffix('\"')) {
return ParsedNodeText {
value: inner.to_string(),
content: SourceSpan::new(raw_start + 1, raw_start + raw.len() - 1),
opening: Some(SourceSpan::new(raw_start, raw_start + 1)),
closing: Some(SourceSpan::new(
raw_start + raw.len() - 1,
raw_start + raw.len(),
)),
is_markdown: false,
};
}
ParsedNodeText {
value: raw.to_string(),
content: SourceSpan::new(raw_start, raw_start + raw.len()),
opening: None,
closing: None,
is_markdown: false,
}
}
fn text_opening_span(raw: &str, raw_start: usize) -> Option<SourceSpan> {
if raw.starts_with("\"`") {
Some(SourceSpan::new(raw_start, raw_start + 2))
} else if raw.starts_with('"') {
Some(SourceSpan::new(raw_start, raw_start + 1))
} else {
None
}
}
fn node_type_for(start: &str, end: &str) -> i32 {
match start {
"[" => NODE_TYPE_RECT,
"(" => {
if end == ")" {
NODE_TYPE_ROUNDED_RECT
} else {
NODE_TYPE_CLOUD
}
}
"((" => NODE_TYPE_CIRCLE,
")" => NODE_TYPE_CLOUD,
"))" => NODE_TYPE_BANG,
"{{" => NODE_TYPE_HEXAGON,
_ => NODE_TYPE_DEFAULT,
}
}
pub(super) fn get_i64(cfg: &MermaidConfig, dotted_path: &str) -> Option<i64> {
let mut cur = cfg.as_value();
for segment in dotted_path.split('.') {
cur = cur.as_object()?.get(segment)?;
}
cur.as_i64().or_else(|| cur.as_f64().map(|f| f as i64))
}