Publish harness and TUI open-source
initial sync from the monorepo
This commit is contained in:
commit
c68e39f604
2734 changed files with 1437016 additions and 0 deletions
659
third_party/mermaid-to-svg/src/parser.rs
vendored
Normal file
659
third_party/mermaid-to-svg/src/parser.rs
vendored
Normal file
|
|
@ -0,0 +1,659 @@
|
|||
use crate::ast::{
|
||||
Edge, EdgeStyle, FlowchartGraph, GraphDirection, Node, NodeShape, Statement, StyleStatement,
|
||||
Subgraph,
|
||||
};
|
||||
use crate::error::MermaidError;
|
||||
|
||||
pub fn parse_mermaid(input: &str) -> Result<FlowchartGraph, MermaidError> {
|
||||
if let Some(first_line) = first_non_empty_non_comment_line(input) {
|
||||
let first_token = first_line.split_whitespace().next().unwrap_or("");
|
||||
|
||||
if first_token != "graph"
|
||||
&& first_token != "flowchart"
|
||||
&& is_known_mermaid_type(first_token)
|
||||
{
|
||||
return Err(MermaidError::UnsupportedDiagramType(
|
||||
first_token.to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let mut parser = Parser::new(input);
|
||||
parser.parse()
|
||||
}
|
||||
|
||||
fn normalize_label(label: &str) -> String {
|
||||
let label = strip_wrapping_quotes(label.trim());
|
||||
decode_html_entities(label)
|
||||
.replace("\\n", "\n")
|
||||
.replace("<br/>", "\n")
|
||||
.replace("<br />", "\n")
|
||||
.replace("<br>", "\n")
|
||||
.replace("<BR/>", "\n")
|
||||
.replace("<BR />", "\n")
|
||||
.replace("<BR>", "\n")
|
||||
}
|
||||
|
||||
fn decode_html_entities(label: &str) -> String {
|
||||
label
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace("'", "'")
|
||||
.replace("&", "&")
|
||||
}
|
||||
|
||||
fn strip_wrapping_quotes(label: &str) -> &str {
|
||||
let bytes = label.as_bytes();
|
||||
if bytes.len() >= 2
|
||||
&& ((bytes[0] == b'"' && bytes[bytes.len() - 1] == b'"')
|
||||
|| (bytes[0] == b'\'' && bytes[bytes.len() - 1] == b'\''))
|
||||
{
|
||||
&label[1..label.len() - 1]
|
||||
} else {
|
||||
label
|
||||
}
|
||||
}
|
||||
|
||||
fn first_non_empty_non_comment_line(input: &str) -> Option<&str> {
|
||||
input
|
||||
.lines()
|
||||
.map(|l| l.trim())
|
||||
.find(|l| !l.is_empty() && !l.starts_with("%%"))
|
||||
}
|
||||
|
||||
fn is_known_mermaid_type(token: &str) -> bool {
|
||||
matches!(
|
||||
token,
|
||||
"sequenceDiagram"
|
||||
| "classDiagram"
|
||||
| "classDiagram-v2"
|
||||
| "stateDiagram"
|
||||
| "stateDiagram-v2"
|
||||
| "erDiagram"
|
||||
| "journey"
|
||||
| "gantt"
|
||||
| "pie"
|
||||
| "mindmap"
|
||||
| "timeline"
|
||||
| "info"
|
||||
| "kanban"
|
||||
| "gitGraph"
|
||||
| "requirementDiagram"
|
||||
| "C4Context"
|
||||
| "C4Container"
|
||||
| "C4Component"
|
||||
| "C4Dynamic"
|
||||
| "C4Deployment"
|
||||
| "sankey-beta"
|
||||
| "packet-beta"
|
||||
| "xychart-beta"
|
||||
| "radar-beta"
|
||||
| "block-beta"
|
||||
| "flowchart-elk"
|
||||
| "quadrantChart"
|
||||
)
|
||||
}
|
||||
|
||||
struct Parser<'a> {
|
||||
lines: Vec<&'a str>,
|
||||
current_line: usize,
|
||||
next_subgraph_index: usize,
|
||||
}
|
||||
|
||||
impl<'a> Parser<'a> {
|
||||
fn new(input: &'a str) -> Self {
|
||||
let lines: Vec<&str> = input.lines().collect();
|
||||
Self {
|
||||
lines,
|
||||
current_line: 0,
|
||||
next_subgraph_index: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse(&mut self) -> Result<FlowchartGraph, MermaidError> {
|
||||
let direction = self.parse_graph_declaration()?;
|
||||
let statements = self.parse_statements()?;
|
||||
|
||||
Ok(FlowchartGraph {
|
||||
direction,
|
||||
statements,
|
||||
})
|
||||
}
|
||||
|
||||
fn current_line_content(&self) -> Option<&'a str> {
|
||||
self.lines.get(self.current_line).map(|s| s.trim())
|
||||
}
|
||||
|
||||
fn advance(&mut self) {
|
||||
self.current_line += 1;
|
||||
}
|
||||
|
||||
fn skip_empty_lines(&mut self) {
|
||||
while let Some(line) = self.current_line_content() {
|
||||
if line.is_empty() || line.starts_with("%%") {
|
||||
self.advance();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_graph_declaration(&mut self) -> Result<GraphDirection, MermaidError> {
|
||||
self.skip_empty_lines();
|
||||
|
||||
let line = self
|
||||
.current_line_content()
|
||||
.ok_or_else(|| MermaidError::ParseError {
|
||||
line: self.current_line + 1,
|
||||
message: "Expected graph declaration".to_string(),
|
||||
})?;
|
||||
|
||||
let direction = if line.starts_with("graph ") || line.starts_with("flowchart ") {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() < 2 {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: self.current_line + 1,
|
||||
message: "Expected direction after 'graph' or 'flowchart'".to_string(),
|
||||
});
|
||||
}
|
||||
self.parse_direction(parts[1])?
|
||||
} else {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: self.current_line + 1,
|
||||
message: "Expected 'graph' or 'flowchart' declaration".to_string(),
|
||||
});
|
||||
};
|
||||
|
||||
self.advance();
|
||||
Ok(direction)
|
||||
}
|
||||
|
||||
fn parse_direction(&self, dir: &str) -> Result<GraphDirection, MermaidError> {
|
||||
match dir.to_uppercase().as_str() {
|
||||
"TD" | "TB" => Ok(GraphDirection::TopToBottom),
|
||||
"BT" => Ok(GraphDirection::BottomToTop),
|
||||
"LR" => Ok(GraphDirection::LeftToRight),
|
||||
"RL" => Ok(GraphDirection::RightToLeft),
|
||||
_ => Err(MermaidError::InvalidDirection(dir.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_statements(&mut self) -> Result<Vec<Statement>, MermaidError> {
|
||||
let mut statements = Vec::new();
|
||||
|
||||
while self.current_line_content().is_some() {
|
||||
self.skip_empty_lines();
|
||||
|
||||
let Some(line) = self.current_line_content() else {
|
||||
break;
|
||||
};
|
||||
|
||||
if line.is_empty() {
|
||||
self.advance();
|
||||
continue;
|
||||
}
|
||||
|
||||
if line == "end" {
|
||||
break;
|
||||
}
|
||||
|
||||
if line.starts_with("subgraph ") {
|
||||
statements.push(Statement::Subgraph(self.parse_subgraph()?));
|
||||
} else if line.starts_with("style ") {
|
||||
statements.push(Statement::Style(self.parse_style()?));
|
||||
} else if self.line_contains_edge(line) {
|
||||
let edge_statements = self.parse_edge_chain(line)?;
|
||||
statements.extend(edge_statements);
|
||||
self.advance();
|
||||
} else {
|
||||
if let Some(node) = self.try_parse_node(line) {
|
||||
statements.push(Statement::Node(node));
|
||||
}
|
||||
self.advance();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(statements)
|
||||
}
|
||||
|
||||
fn line_contains_edge(&self, line: &str) -> bool {
|
||||
self.find_edge_start(line).is_some()
|
||||
}
|
||||
|
||||
fn parse_edge_chain(&mut self, line: &str) -> Result<Vec<Statement>, MermaidError> {
|
||||
let mut statements = Vec::new();
|
||||
let mut remaining = line.trim();
|
||||
let mut collected_nodes: Vec<(String, Option<Node>)> = Vec::new();
|
||||
|
||||
let first_node_end = self.find_edge_start(remaining).unwrap_or(remaining.len());
|
||||
let first_node_str = remaining[..first_node_end].trim();
|
||||
if let Some(node) = self.try_parse_node(first_node_str) {
|
||||
collected_nodes.push((node.id.clone(), Some(node)));
|
||||
} else {
|
||||
let id = self.extract_node_id(first_node_str);
|
||||
collected_nodes.push((id, None));
|
||||
}
|
||||
remaining = &remaining[first_node_end..];
|
||||
|
||||
while !remaining.is_empty() {
|
||||
let (edge_style, label, edge_len) = self.parse_edge_syntax(remaining)?;
|
||||
remaining = remaining[edge_len..].trim_start();
|
||||
|
||||
let next_node_end = self.find_edge_start(remaining).unwrap_or(remaining.len());
|
||||
let next_node_str = remaining[..next_node_end].trim();
|
||||
|
||||
if next_node_str.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
let (next_id, next_node) = if let Some(node) = self.try_parse_node(next_node_str) {
|
||||
(node.id.clone(), Some(node))
|
||||
} else {
|
||||
let id = self.extract_node_id(next_node_str);
|
||||
(id, None)
|
||||
};
|
||||
|
||||
if let Some((from_id, _)) = collected_nodes.last() {
|
||||
statements.push(Statement::Edge(Edge {
|
||||
from: from_id.clone(),
|
||||
to: next_id.clone(),
|
||||
label,
|
||||
style: edge_style,
|
||||
}));
|
||||
}
|
||||
|
||||
collected_nodes.push((next_id, next_node));
|
||||
remaining = &remaining[next_node_end..];
|
||||
}
|
||||
|
||||
let mut node_statements: Vec<Statement> = collected_nodes
|
||||
.into_iter()
|
||||
.filter_map(|(_, node_opt)| node_opt.map(Statement::Node))
|
||||
.collect();
|
||||
node_statements.append(&mut statements);
|
||||
statements = node_statements;
|
||||
|
||||
Ok(statements)
|
||||
}
|
||||
|
||||
/// Byte index where the first edge token starts, ignoring tokens inside
|
||||
/// bracket/quote-delimited node labels (`[..]`, `(..)`, `{..}`, `".."`).
|
||||
fn find_edge_start(&self, s: &str) -> Option<usize> {
|
||||
const PATTERNS: [&str; 9] = ["-.->", "-.-", "-->", "---", "==>", "===", "--", "==", "-."];
|
||||
let bytes = s.as_bytes();
|
||||
let mut depth: usize = 0;
|
||||
let mut in_quote = false;
|
||||
for i in 0..bytes.len() {
|
||||
let b = bytes[i];
|
||||
if in_quote {
|
||||
if b == b'"' {
|
||||
in_quote = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match b {
|
||||
b'"' => in_quote = true,
|
||||
b'[' | b'(' | b'{' => depth += 1,
|
||||
b']' | b')' | b'}' => depth = depth.saturating_sub(1),
|
||||
_ if depth == 0 => {
|
||||
if PATTERNS
|
||||
.iter()
|
||||
.any(|p| bytes[i..].starts_with(p.as_bytes()))
|
||||
{
|
||||
return Some(i);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_edge_syntax(
|
||||
&self,
|
||||
s: &str,
|
||||
) -> Result<(EdgeStyle, Option<String>, usize), MermaidError> {
|
||||
let s = s.trim_start();
|
||||
|
||||
let edge_patterns: &[(&str, EdgeStyle, &str)] = &[
|
||||
("-->|", EdgeStyle::Arrow, "|"),
|
||||
("---|", EdgeStyle::Line, "|"),
|
||||
("-.->|", EdgeStyle::DottedArrow, "|"),
|
||||
("-.-|", EdgeStyle::DottedLine, "|"),
|
||||
("==>|", EdgeStyle::ThickArrow, "|"),
|
||||
("===|", EdgeStyle::ThickLine, "|"),
|
||||
("-->", EdgeStyle::Arrow, ""),
|
||||
("---", EdgeStyle::Line, ""),
|
||||
("-.->", EdgeStyle::DottedArrow, ""),
|
||||
("-.-", EdgeStyle::DottedLine, ""),
|
||||
("==>", EdgeStyle::ThickArrow, ""),
|
||||
("===", EdgeStyle::ThickLine, ""),
|
||||
];
|
||||
|
||||
for (pattern, style, label_end) in edge_patterns {
|
||||
if let Some(after_pattern) = s.strip_prefix(pattern) {
|
||||
if !label_end.is_empty() {
|
||||
if let Some(end_idx) = after_pattern.find(label_end) {
|
||||
let label = normalize_label(&after_pattern[..end_idx]);
|
||||
let total_len = pattern.len() + end_idx + label_end.len();
|
||||
return Ok((*style, Some(label), total_len));
|
||||
}
|
||||
} else {
|
||||
return Ok((*style, None, pattern.len()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Open-label forms: `-- text -->`, `-- text ---`, `== text ==>`,
|
||||
// `== text ===`, `-. text .->`, `-. text .-`.
|
||||
let open_patterns: &[(&str, &[(&str, EdgeStyle)])] = &[
|
||||
("--", &[("-->", EdgeStyle::Arrow), ("---", EdgeStyle::Line)]),
|
||||
(
|
||||
"==",
|
||||
&[
|
||||
("==>", EdgeStyle::ThickArrow),
|
||||
("===", EdgeStyle::ThickLine),
|
||||
],
|
||||
),
|
||||
(
|
||||
"-.",
|
||||
&[
|
||||
(".->", EdgeStyle::DottedArrow),
|
||||
(".-", EdgeStyle::DottedLine),
|
||||
],
|
||||
),
|
||||
];
|
||||
for (opener, closers) in open_patterns {
|
||||
let Some(after) = s.strip_prefix(opener) else {
|
||||
continue;
|
||||
};
|
||||
let mut best: Option<(usize, &str, EdgeStyle)> = None;
|
||||
for (closer, style) in *closers {
|
||||
if let Some(idx) = after.find(closer) {
|
||||
let better = match best {
|
||||
Some((best_idx, best_closer, _)) => {
|
||||
idx < best_idx || (idx == best_idx && closer.len() > best_closer.len())
|
||||
}
|
||||
None => true,
|
||||
};
|
||||
if better {
|
||||
best = Some((idx, closer, *style));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((idx, closer, style)) = best {
|
||||
let label = normalize_label(&after[..idx]);
|
||||
let total_len = opener.len() + idx + closer.len();
|
||||
return Ok((style, Some(label), total_len));
|
||||
}
|
||||
}
|
||||
|
||||
Err(MermaidError::ParseError {
|
||||
line: self.current_line + 1,
|
||||
message: format!("Invalid edge syntax: {}", s),
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_node_id(&self, s: &str) -> String {
|
||||
let s = s.trim();
|
||||
for (open, _close) in [('[', ']'), ('(', ')'), ('{', '}'), ('<', '>')] {
|
||||
if let Some(idx) = s.find(open) {
|
||||
return s[..idx].trim().to_string();
|
||||
}
|
||||
}
|
||||
s.to_string()
|
||||
}
|
||||
|
||||
fn try_parse_node(&self, s: &str) -> Option<Node> {
|
||||
let s = s.trim();
|
||||
if s.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(paren_paren_start) = s.find("((") {
|
||||
if s.ends_with("))") {
|
||||
let id = s[..paren_paren_start].trim().to_string();
|
||||
let label = normalize_label(&s[paren_paren_start + 2..s.len() - 2]);
|
||||
let id = if id.is_empty() {
|
||||
label.chars().filter(|c| c.is_alphanumeric()).collect()
|
||||
} else {
|
||||
id
|
||||
};
|
||||
return Some(Node {
|
||||
id,
|
||||
label: Some(label),
|
||||
shape: NodeShape::Circle,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(bracket_paren_start) = s.find("([") {
|
||||
if s.ends_with("])") {
|
||||
let id = s[..bracket_paren_start].trim().to_string();
|
||||
let label = normalize_label(&s[bracket_paren_start + 2..s.len() - 2]);
|
||||
let id = if id.is_empty() {
|
||||
label.chars().filter(|c| c.is_alphanumeric()).collect()
|
||||
} else {
|
||||
id
|
||||
};
|
||||
return Some(Node {
|
||||
id,
|
||||
label: Some(label),
|
||||
shape: NodeShape::Stadium,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(paren_bracket_start) = s.find("[(") {
|
||||
if s.ends_with(")]") {
|
||||
let id = s[..paren_bracket_start].trim().to_string();
|
||||
let label = normalize_label(&s[paren_bracket_start + 2..s.len() - 2]);
|
||||
let id = if id.is_empty() {
|
||||
label.chars().filter(|c| c.is_alphanumeric()).collect()
|
||||
} else {
|
||||
id
|
||||
};
|
||||
return Some(Node {
|
||||
id,
|
||||
label: Some(label),
|
||||
shape: NodeShape::Cylinder,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(bracket_bracket_start) = s.find("[[") {
|
||||
if s.ends_with("]]") {
|
||||
let id = s[..bracket_bracket_start].trim().to_string();
|
||||
let label = normalize_label(&s[bracket_bracket_start + 2..s.len() - 2]);
|
||||
let id = if id.is_empty() {
|
||||
label.chars().filter(|c| c.is_alphanumeric()).collect()
|
||||
} else {
|
||||
id
|
||||
};
|
||||
return Some(Node {
|
||||
id,
|
||||
label: Some(label),
|
||||
shape: NodeShape::Subroutine,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(brace_brace_start) = s.find("{{") {
|
||||
if s.ends_with("}}") {
|
||||
let id = s[..brace_brace_start].trim().to_string();
|
||||
let label = normalize_label(&s[brace_brace_start + 2..s.len() - 2]);
|
||||
let id = if id.is_empty() {
|
||||
label.chars().filter(|c| c.is_alphanumeric()).collect()
|
||||
} else {
|
||||
id
|
||||
};
|
||||
return Some(Node {
|
||||
id,
|
||||
label: Some(label),
|
||||
shape: NodeShape::Hexagon,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(bracket_start) = s.find('[') {
|
||||
if s.ends_with(']') {
|
||||
let id = s[..bracket_start].trim().to_string();
|
||||
let label = normalize_label(&s[bracket_start + 1..s.len() - 1]);
|
||||
let id = if id.is_empty() {
|
||||
label.chars().filter(|c| c.is_alphanumeric()).collect()
|
||||
} else {
|
||||
id
|
||||
};
|
||||
return Some(Node {
|
||||
id,
|
||||
label: Some(label),
|
||||
shape: NodeShape::Rectangle,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(paren_start) = s.find('(') {
|
||||
if s.ends_with(')') && !s.ends_with("))") {
|
||||
let id = s[..paren_start].trim().to_string();
|
||||
let label = normalize_label(&s[paren_start + 1..s.len() - 1]);
|
||||
let id = if id.is_empty() {
|
||||
label.chars().filter(|c| c.is_alphanumeric()).collect()
|
||||
} else {
|
||||
id
|
||||
};
|
||||
return Some(Node {
|
||||
id,
|
||||
label: Some(label),
|
||||
shape: NodeShape::RoundedRectangle,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(brace_start) = s.find('{') {
|
||||
if s.ends_with('}') && !s.ends_with("}}") {
|
||||
let id = s[..brace_start].trim().to_string();
|
||||
let label = normalize_label(&s[brace_start + 1..s.len() - 1]);
|
||||
let id = if id.is_empty() {
|
||||
label.chars().filter(|c| c.is_alphanumeric()).collect()
|
||||
} else {
|
||||
id
|
||||
};
|
||||
return Some(Node {
|
||||
id,
|
||||
label: Some(label),
|
||||
shape: NodeShape::Diamond,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if s.contains('>') && s.ends_with(']') {
|
||||
if let Some(gt_idx) = s.find('>') {
|
||||
let id = s[..gt_idx].trim().to_string();
|
||||
let label = normalize_label(&s[gt_idx + 1..s.len() - 1]);
|
||||
return Some(Node {
|
||||
id,
|
||||
label: Some(label),
|
||||
shape: NodeShape::Asymmetric,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if s.chars().all(|c| c.is_alphanumeric() || c == '_') {
|
||||
return Some(Node {
|
||||
id: s.to_string(),
|
||||
label: None,
|
||||
shape: NodeShape::Rectangle,
|
||||
});
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_subgraph(&mut self) -> Result<Subgraph, MermaidError> {
|
||||
let line = self
|
||||
.current_line_content()
|
||||
.ok_or_else(|| MermaidError::ParseError {
|
||||
line: self.current_line + 1,
|
||||
message: "Expected subgraph".to_string(),
|
||||
})?;
|
||||
|
||||
let after_keyword = line.strip_prefix("subgraph ").unwrap_or("").trim();
|
||||
|
||||
let (id, title) = if let Some(bracket_start) = after_keyword.find('[') {
|
||||
if after_keyword.ends_with(']') {
|
||||
let id = after_keyword[..bracket_start].trim().to_string();
|
||||
let title =
|
||||
normalize_label(&after_keyword[bracket_start + 1..after_keyword.len() - 1]);
|
||||
(id, Some(title))
|
||||
} else {
|
||||
(after_keyword.to_string(), None)
|
||||
}
|
||||
} else if after_keyword.split_whitespace().count() > 1 {
|
||||
let id = format!("subGraph{}", self.next_subgraph_index);
|
||||
self.next_subgraph_index += 1;
|
||||
(id, Some(normalize_label(after_keyword)))
|
||||
} else {
|
||||
let id = after_keyword.to_string();
|
||||
(id, None)
|
||||
};
|
||||
|
||||
self.advance();
|
||||
|
||||
let statements = self.parse_statements()?;
|
||||
|
||||
if self.current_line_content() == Some("end") {
|
||||
self.advance();
|
||||
}
|
||||
|
||||
Ok(Subgraph {
|
||||
id,
|
||||
title,
|
||||
statements,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_style(&mut self) -> Result<StyleStatement, MermaidError> {
|
||||
let line = self
|
||||
.current_line_content()
|
||||
.ok_or_else(|| MermaidError::ParseError {
|
||||
line: self.current_line + 1,
|
||||
message: "Expected style statement".to_string(),
|
||||
})?;
|
||||
|
||||
let after_keyword = line.strip_prefix("style ").unwrap_or("").trim();
|
||||
let parts: Vec<&str> = after_keyword.splitn(2, ' ').collect();
|
||||
|
||||
if parts.is_empty() {
|
||||
return Err(MermaidError::ParseError {
|
||||
line: self.current_line + 1,
|
||||
message: "Expected node id after 'style'".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let node_id = parts[0].to_string();
|
||||
let properties = if parts.len() > 1 {
|
||||
parts[1]
|
||||
.split(',')
|
||||
.filter_map(|prop| {
|
||||
let kv: Vec<&str> = prop.splitn(2, ':').collect();
|
||||
if kv.len() == 2 {
|
||||
Some((kv[0].trim().to_string(), kv[1].trim().to_string()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
self.advance();
|
||||
|
||||
Ok(StyleStatement {
|
||||
node_id,
|
||||
properties,
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue