#![allow(dead_code)]
use crate::preview::mermaid::chart::{
find_header, first_word, Envelope, ParseError, Preamble, Source, TitleSyntax,
};
use crate::preview::mermaid::flowchart::Direction;
pub const KEYWORD: &str = "requirementDiagram";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Kind {
#[default]
Requirement,
Functional,
Interface,
Performance,
Physical,
DesignConstraint,
}
impl Kind {
pub fn title(self) -> &'static str {
match self {
Kind::Requirement => "Requirement",
Kind::Functional => "Functional Requirement",
Kind::Interface => "Interface Requirement",
Kind::Performance => "Performance Requirement",
Kind::Physical => "Physical Requirement",
Kind::DesignConstraint => "Design Constraint",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Relation {
Contains,
Copies,
Derives,
Satisfies,
Verifies,
Refines,
Traces,
}
impl Relation {
pub fn word(self) -> &'static str {
match self {
Relation::Contains => "contains",
Relation::Copies => "copies",
Relation::Derives => "derives",
Relation::Satisfies => "satisfies",
Relation::Verifies => "verifies",
Relation::Refines => "refines",
Relation::Traces => "traces",
}
}
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Requirement {
pub name: String,
pub kind: Kind,
pub id: String,
pub text: String,
pub risk: String,
pub verify_method: String,
pub classes: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Element {
pub name: String,
pub kind: String,
pub doc_ref: String,
pub classes: Vec<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Link {
pub relation: Relation,
pub src: String,
pub dst: String,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct RequirementDiagram {
pub preamble: Preamble,
pub direction: Direction,
pub requirements: Vec<Requirement>,
pub elements: Vec<Element>,
pub links: Vec<Link>,
}
pub fn is_requirement_diagram(src: &str) -> bool {
find_header(src, &[KEYWORD]).is_some()
}
pub fn parse(src: &str) -> Result<RequirementDiagram, ParseError> {
let Some(Source {
lines,
header_index,
header_rest,
front_matter_title,
}) = find_header(src, &[KEYWORD])
else {
return Err(ParseError::NotThisChart {
expected: KEYWORD,
header: first_word(src),
});
};
let mut diagram = RequirementDiagram::default();
let mut env = Envelope::new(front_matter_title);
let mut open: Option<Body> = None;
let mut all: Vec<(usize, &str)> = Vec::new();
if !header_rest.is_empty() {
all.push((header_index + 1, header_rest.as_str()));
}
for (i, line) in lines.iter().enumerate().skip(header_index + 1) {
all.push((i + 1, line.as_str()));
}
for (number, line) in all {
if open.is_none() && env.read(line, TitleSyntax::RawRestOfLine) {
continue;
}
let t = strip_comment(line).trim();
if t.is_empty() {
continue;
}
if let Some(body) = open.as_mut() {
if t == "}" {
close_body(&mut diagram, open.take().expect("open"));
continue;
}
read_body_line(body, t, number)?;
continue;
}
if let Some(d) = direction_of(t) {
diagram.direction = d;
continue;
}
if starts_word(t, "style") || starts_word(t, "classDef") || starts_word(t, "class") {
continue;
}
if let Some(link) = read_relationship(t) {
diagram.links.push(link);
continue;
}
if let Some(body) = read_definition(t, number)? {
open = Some(body);
continue;
}
return Err(ParseError::Unexpected {
kind: KEYWORD,
line: number,
text: t.to_string(),
});
}
if let Some(body) = open {
return Err(ParseError::Invalid {
line: body.line,
message: format!("`{}` was opened with `{{` and never closed", body.name),
});
}
diagram.preamble = env.preamble;
if diagram.requirements.is_empty() && diagram.elements.is_empty() {
return Err(ParseError::NoData {
kind: KEYWORD,
wanted: "requirement",
});
}
Ok(diagram)
}
struct Body {
line: usize,
name: String,
classes: Vec<String>,
kind: Option<Kind>,
requirement: Requirement,
element: Element,
}
fn close_body(diagram: &mut RequirementDiagram, body: Body) {
match body.kind {
Some(kind) => {
if diagram.requirements.iter().any(|r| r.name == body.name) {
return;
}
diagram.requirements.push(Requirement {
name: body.name,
kind,
classes: body.classes,
..body.requirement
});
}
None => {
if diagram.elements.iter().any(|e| e.name == body.name) {
return;
}
diagram.elements.push(Element {
name: body.name,
classes: body.classes,
..body.element
});
}
}
}
fn read_definition(t: &str, number: usize) -> Result<Option<Body>, ParseError> {
const TYPES: &[(&str, Kind)] = &[
("functionalRequirement", Kind::Functional),
("interfaceRequirement", Kind::Interface),
("performanceRequirement", Kind::Performance),
("physicalRequirement", Kind::Physical),
("designConstraint", Kind::DesignConstraint),
("requirement", Kind::Requirement),
];
let (kind, rest) = match TYPES
.iter()
.find_map(|(word, k)| word_rest(t, word).map(|r| (Some(*k), r)))
{
Some(hit) => hit,
None => match word_rest(t, "element") {
Some(r) => (None, r),
None => return Ok(None),
},
};
let Some(brace) = rest.rfind('{') else {
return Err(ParseError::Unexpected {
kind: KEYWORD,
line: number,
text: t.to_string(),
});
};
if !rest[brace + 1..].trim().is_empty() {
return Err(ParseError::Unexpected {
kind: KEYWORD,
line: number,
text: t.to_string(),
});
}
let head = rest[..brace].trim();
let (name, classes) = match head.split_once(":::") {
Some((n, c)) => (
n.trim(),
c.split(',').map(|s| s.trim().to_string()).collect(),
),
None => (head, Vec::new()),
};
let name = unquote(name);
if name.is_empty() {
return Err(ParseError::Unexpected {
kind: KEYWORD,
line: number,
text: t.to_string(),
});
}
Ok(Some(Body {
line: number,
name,
classes,
kind,
requirement: Requirement::default(),
element: Element::default(),
}))
}
fn read_body_line(body: &mut Body, t: &str, number: usize) -> Result<(), ParseError> {
let Some((key, value)) = t.split_once(':') else {
return Err(ParseError::Unexpected {
kind: KEYWORD,
line: number,
text: t.to_string(),
});
};
let value = unquote(value.trim());
let key = key.trim().to_ascii_lowercase();
let is_requirement = body.kind.is_some();
match (is_requirement, key.as_str()) {
(true, "id") => body.requirement.id = value,
(true, "text") => body.requirement.text = value,
(true, "risk") => {
body.requirement.risk = match value.to_ascii_lowercase().as_str() {
"low" => "Low",
"medium" => "Medium",
"high" => "High",
_ => {
return Err(ParseError::Invalid {
line: number,
message: format!("`{value}` is not one of low, medium, high"),
})
}
}
.to_string()
}
(true, "verifymethod") => {
body.requirement.verify_method = match value.to_ascii_lowercase().as_str() {
"analysis" => "Analysis",
"demonstration" => "Demonstration",
"inspection" => "Inspection",
"test" => "Test",
_ => {
return Err(ParseError::Invalid {
line: number,
message: format!(
"`{value}` is not one of analysis, demonstration, inspection, test"
),
})
}
}
.to_string()
}
(false, "type") => body.element.kind = value,
(false, "docref") => body.element.doc_ref = value,
_ => {
return Err(ParseError::Unexpected {
kind: KEYWORD,
line: number,
text: t.to_string(),
})
}
}
Ok(())
}
fn read_relationship(t: &str) -> Option<Link> {
const VERBS: &[(&str, Relation)] = &[
("contains", Relation::Contains),
("copies", Relation::Copies),
("derives", Relation::Derives),
("satisfies", Relation::Satisfies),
("verifies", Relation::Verifies),
("refines", Relation::Refines),
("traces", Relation::Traces),
];
if let Some((left, rest)) = t.split_once("<-") {
let (verb, right) = rest.split_once('-')?;
let relation = VERBS
.iter()
.find(|(w, _)| verb.trim().eq_ignore_ascii_case(w))
.map(|(_, r)| *r)?;
return Some(Link {
relation,
src: unquote(right.trim()),
dst: unquote(left.trim()),
});
}
let (left, rest) = t.split_once('-')?;
let (verb, right) = rest.split_once("->")?;
let relation = VERBS
.iter()
.find(|(w, _)| verb.trim().eq_ignore_ascii_case(w))
.map(|(_, r)| *r)?;
Some(Link {
relation,
src: unquote(left.trim()),
dst: unquote(right.trim()),
})
}
fn direction_of(t: &str) -> Option<Direction> {
let at = t.find("direction")?;
let rest = t[at + "direction".len()..].trim_start();
let word = rest.split_whitespace().next()?;
match word.to_ascii_uppercase().as_str() {
"TB" => Some(Direction::TopToBottom),
"BT" => Some(Direction::BottomToTop),
"RL" => Some(Direction::RightToLeft),
"LR" => Some(Direction::LeftToRight),
_ => None,
}
}
fn strip_comment(line: &str) -> &str {
let mut end = line.len();
if let Some(i) = line.find('#') {
end = end.min(i);
}
if let Some(i) = line.find("%%") {
end = end.min(i);
}
&line[..end]
}
fn word_rest<'a>(t: &'a str, word: &str) -> Option<&'a str> {
if !t
.get(..word.len())
.is_some_and(|h| h.eq_ignore_ascii_case(word))
{
return None;
}
let after = &t[word.len()..];
after.starts_with([' ', '\t']).then(|| after.trim_start())
}
fn starts_word(t: &str, word: &str) -> bool {
word_rest(t, word).is_some()
}
fn unquote(v: &str) -> String {
let v = v.trim();
if v.len() >= 2 && v.starts_with('"') && v.ends_with('"') {
return v[1..v.len() - 1].to_string();
}
v.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
fn ok(src: &str) -> RequirementDiagram {
parse(src).unwrap_or_else(|e| panic!("{e}\n{src}"))
}
#[test]
fn the_documented_example_reads_as_a_requirement_an_element_and_a_link() {
let d = ok("requirementDiagram\n\n requirement test_req {\n id: 1\n text: the test text.\n risk: high\n verifymethod: test\n }\n\n element test_entity {\n type: simulation\n }\n\n test_entity - satisfies -> test_req\n");
assert_eq!(d.requirements.len(), 1);
let r = &d.requirements[0];
assert_eq!(r.name, "test_req");
assert_eq!(r.kind, Kind::Requirement);
assert_eq!(r.id, "1");
assert_eq!(r.text, "the test text.");
assert_eq!(r.risk, "High");
assert_eq!(r.verify_method, "Test");
assert_eq!(d.elements[0].name, "test_entity");
assert_eq!(d.elements[0].kind, "simulation");
assert_eq!(
d.links[0],
Link {
relation: Relation::Satisfies,
src: "test_entity".to_string(),
dst: "test_req".to_string(),
}
);
}
#[test]
fn the_backwards_arrow_swaps_the_source_and_the_destination() {
let d = ok("requirementDiagram\n requirement a {\n }\n requirement b {\n }\n a <- satisfies - b\n");
assert_eq!(d.links[0].src, "b");
assert_eq!(d.links[0].dst, "a");
}
#[test]
fn every_documented_requirement_type_is_read() {
for (word, want) in [
("requirement", Kind::Requirement),
("functionalRequirement", Kind::Functional),
("interfaceRequirement", Kind::Interface),
("performanceRequirement", Kind::Performance),
("physicalRequirement", Kind::Physical),
("designConstraint", Kind::DesignConstraint),
] {
let d = ok(&format!(
"requirementDiagram\n {word} x {{\n id: 1\n }}\n"
));
assert_eq!(d.requirements[0].kind, want, "{word}");
}
}
#[test]
fn every_documented_risk_and_verify_method_is_read() {
for (risk, want) in [("low", "Low"), ("medium", "Medium"), ("high", "High")] {
let d = ok(&format!(
"requirementDiagram\n requirement x {{\n risk: {risk}\n }}\n"
));
assert_eq!(d.requirements[0].risk, want);
}
for (m, want) in [
("analysis", "Analysis"),
("demonstration", "Demonstration"),
("inspection", "Inspection"),
("test", "Test"),
] {
let d = ok(&format!(
"requirementDiagram\n requirement x {{\n verifyMethod: {m}\n }}\n"
));
assert_eq!(d.requirements[0].verify_method, want);
}
}
#[test]
fn a_quoted_name_and_a_quoted_value_lose_their_quotes() {
let d = ok("requirementDiagram\n requirement \"__test_req__\" {\n id: 1\n text: \"*italicized text* **bold text**\"\n }\n");
assert_eq!(d.requirements[0].name, "__test_req__");
assert_eq!(d.requirements[0].text, "*italicized text* **bold text**");
}
#[test]
fn an_unknown_body_key_is_refused_rather_than_dropped() {
assert!(matches!(
parse("requirementDiagram\n requirement x {\n colour: red\n }\n"),
Err(ParseError::Unexpected { .. })
));
assert!(matches!(
parse("requirementDiagram\n requirement x {\n risk: catastrophic\n }\n"),
Err(ParseError::Invalid { .. })
));
}
#[test]
fn a_body_that_is_never_closed_is_refused() {
assert!(matches!(
parse("requirementDiagram\n requirement x {\n id: 1\n"),
Err(ParseError::Invalid { .. })
));
}
#[test]
fn direction_and_the_colour_statements_are_read_and_do_not_become_nodes() {
let d = ok("requirementDiagram\n direction LR\n classDef hot fill:#f00\n style x fill:#0f0\n requirement x {\n id: 1\n }\n class x hot\n");
assert_eq!(d.direction, Direction::LeftToRight);
assert_eq!(d.requirements.len(), 1, "no phantom nodes");
}
#[test]
fn a_style_separator_on_the_definition_is_read_as_classes() {
let d = ok("requirementDiagram\n requirement x:::hot {\n id: 1\n }\n");
assert_eq!(d.requirements[0].name, "x");
assert_eq!(d.requirements[0].classes, ["hot"]);
}
#[test]
fn a_header_with_nothing_in_it_is_refused() {
assert!(matches!(
parse("requirementDiagram\n"),
Err(ParseError::NoData { .. })
));
}
#[test]
fn the_routing_predicate_and_the_parser_agree() {
for src in [
"requirementDiagram\n requirement x {\n }",
"REQUIREMENTDIAGRAM\n element x {\n }",
"requirementDiagram",
"requirementDiagrams\n a",
"gantt\n title G",
"",
"%% only a comment\n",
] {
let refused = matches!(parse(src), Err(ParseError::NotThisChart { .. }));
assert_eq!(is_requirement_diagram(src), !refused, "{src:?}");
}
}
}