use std::collections::BTreeMap;
use std::fmt::Write;
use super::{Labels, decisions, state_root};
use crate::attributes::{QualityBand, QualityTarget, TechnicalBinding, VerificationFate};
use crate::document::{Document, Edge};
use crate::hash::DocumentHashes;
use crate::ids::{ElementId, NodeId};
use crate::node::Node;
use crate::tree::{Component, Section};
pub(super) fn render(document: &Document, hashes: &DocumentHashes, labels: &Labels) -> String {
let title = document
.products
.first()
.map_or("Specification", |product| product.name.as_str());
let mut out = format!(
"# {}\n\n> {} ยท state root `{}`\n\n",
escape_inline(title),
crate::FORMAT,
state_root(hashes)
);
let mut nodes = BTreeMap::new();
document.walk(&mut |node| {
if let Some(id) = node.id() {
nodes.insert(id, node);
}
});
let mut renderer = Renderer {
out: &mut out,
document,
labels,
nodes: &nodes,
};
renderer.capabilities();
renderer.components();
decisions::render(renderer.out, document, labels, &nodes);
renderer.registries();
renderer.realizations();
while out.ends_with("\n\n") {
out.pop();
}
if !out.ends_with('\n') {
out.push('\n');
}
out
}
struct Renderer<'a> {
out: &'a mut String,
document: &'a Document,
labels: &'a Labels,
nodes: &'a Nodes<'a>,
}
pub(super) type Nodes<'a> = BTreeMap<NodeId, Node<'a>>;
impl Renderer<'_> {
fn none_if_empty(&mut self, empty: bool) -> bool {
if empty {
self.out.push_str("_None._\n\n");
}
empty
}
fn capabilities(&mut self) {
self.out.push_str("## Capability Model\n\n");
if self.none_if_empty(self.document.products.is_empty()) {
return;
}
for product in &self.document.products {
self.capability(Node::Product(product), 3);
}
}
fn capability(&mut self, node: Node<'_>, level: usize) {
self.heading(node, level);
self.body_tags_and_sections(node, level);
let children = node.children();
for child in children.iter().filter(|child| is_overlay(**child)) {
self.overlay(*child);
}
for child in children.iter().filter(|child| is_structural(**child)) {
self.capability(*child, (level + 1).min(6));
}
}
fn components(&mut self) {
self.out.push_str("## Component Model\n\n");
if self.none_if_empty(self.document.components.is_empty()) {
return;
}
for root in &self.document.components {
self.component(&root.component, 3);
}
}
fn component(&mut self, component: &Component, level: usize) {
let node = Node::Component(component);
self.heading(node, level);
let scopes = self.document.edges.iter().filter_map(|edge| match *edge {
Edge::ScopedTo {
component: from,
product,
} if Some(from) == component.id => Some(product),
_ => None,
});
for product in scopes {
let _ = writeln!(self.out, "Scope: Product `{}`\n", self.labels.name(product));
}
if let Some(boundary) = component.boundary {
let _ = writeln!(self.out, "Boundary: `{}`\n", boundary.as_str());
}
self.body_tags_and_sections(node, level);
for child in node
.children()
.into_iter()
.filter(|child| is_overlay(*child))
{
self.overlay(child);
}
for child in &component.components {
self.component(child, (level + 1).min(6));
}
}
fn heading(&mut self, node: Node<'_>, level: usize) {
let name = node.name();
let label = node
.id()
.map_or_else(String::new, |id| self.labels.name(id));
let _ = write!(
self.out,
"{} {label} ยท {}\n\n",
"#".repeat(level),
escape_inline(&name)
);
}
fn body_tags_and_sections(&mut self, node: Node<'_>, level: usize) {
let Some(details) = node.details() else {
return;
};
body(self.out, details.body.as_deref());
if !details.tags.is_empty() {
let tags: Vec<String> = details.tags.iter().map(|tag| format!("`{tag}`")).collect();
let _ = writeln!(self.out, "Tags: {}\n", tags.join(", "));
}
for section in &details.sections {
self.section(section, (level + 1).min(6));
}
}
fn section(&mut self, section: &Section, level: usize) {
let _ = write!(
self.out,
"{} {}\n\n",
"#".repeat(level),
escape_inline(§ion.name)
);
body(self.out, Some(§ion.body));
for child in §ion.sections {
self.section(child, (level + 1).min(6));
}
}
fn overlay(&mut self, node: Node<'_>) {
let label = node
.id()
.map_or_else(String::new, |id| self.labels.name(id));
let _ = write!(
self.out,
"**{label} ยท {} ยท {}**\n\n",
kind_label(node),
escape_inline(&node.name())
);
match node {
Node::Constraint(constraint) => {
if let Some(binding) = &constraint.binding {
technical_binding(self.out, binding);
}
}
Node::QualityAttribute(attribute) => quality_target(
self.out,
attribute.kind.as_str(),
&attribute.target,
attribute.verification_fate,
),
_ => {}
}
self.body_tags_and_sections(node, 6);
}
fn registries(&mut self) {
self.out.push_str("## Domain Vocabulary\n\n");
let mut tags: Vec<_> = self.document.tags.iter().collect();
tags.sort_by_key(|entry| (entry.name.as_str(), entry.id));
if !self.none_if_empty(tags.is_empty()) {
for entry in tags {
let name = escape_inline(entry.name.as_str());
let _ = write!(self.out, "### {name}\n\n{}\n\n", entry.definition);
}
}
self.out.push_str("## Technical Constraint Categories\n\n");
let mut categories: Vec<_> = self.document.categories.iter().collect();
categories.sort_by_key(|entry| (entry.name.as_str(), entry.id));
if !self.none_if_empty(categories.is_empty()) {
for entry in categories {
let _ = write!(self.out, "### {}\n\n{}\n\n", entry.name, entry.definition);
}
}
}
fn realizations(&mut self) {
self.out.push_str("## Realization Links\n\n");
let mut links: Vec<_> = self
.document
.edges
.iter()
.filter_map(|edge| match *edge {
Edge::Realizes { feature, component } => Some((feature, component)),
_ => None,
})
.collect();
links.sort_unstable();
if links.is_empty() {
self.out.push_str("_None._\n");
return;
}
self.out
.push_str("| Feature | Component |\n| --- | --- |\n");
for (feature, component) in links {
let feature = self.link_label(feature.into());
let component = self.link_label(component.into());
let _ = writeln!(self.out, "| {feature} | {component} |");
}
}
fn link_label(&self, id: NodeId) -> String {
let name = self
.nodes
.get(&id)
.copied()
.map(|node| node.name().into_owned())
.unwrap_or_default();
format!(
"{} ยท {}",
self.labels.name(ElementId::Node(id)),
escape_inline(&name)
)
}
}
const fn is_overlay(node: Node<'_>) -> bool {
matches!(
node,
Node::Criterion(_) | Node::Constraint(_) | Node::QualityAttribute(_)
)
}
const fn is_structural(node: Node<'_>) -> bool {
matches!(
node,
Node::FeatureGroup(_) | Node::Feature(_) | Node::Requirement(_)
)
}
const fn kind_label(node: Node<'_>) -> &'static str {
match node {
Node::Criterion(_) => "Acceptance Criterion",
Node::Constraint(_) => "Constraint",
Node::QualityAttribute(_) => "Quality Attribute",
_ => "Node",
}
}
fn body(out: &mut String, body: Option<&str>) {
if let Some(body) = body {
out.push_str(body);
if !body.ends_with('\n') {
out.push('\n');
}
out.push('\n');
}
}
fn technical_binding(out: &mut String, binding: &TechnicalBinding) {
let value = serde_json::to_string(&binding.required_value).expect("a string serializes");
let _ = writeln!(
out,
"Technical constraint: `{}` requires {}\n",
binding.category,
code_span(&value)
);
}
fn quality_target(
out: &mut String,
kind: &str,
target: &QualityTarget,
fate: Option<VerificationFate>,
) {
let band = match &target.band {
QualityBand::AtMost { upper } => format!("<= {}", upper.as_str()),
QualityBand::AtLeast { lower } => format!(">= {}", lower.as_str()),
QualityBand::Exactly { value } => format!("= {}", value.as_str()),
QualityBand::Between { lower, upper } => {
format!("[{}, {}]", lower.as_str(), upper.as_str())
}
};
let fate = fate.map_or_else(String::new, |fate| format!("; fate `{}`", fate.as_str()));
let _ = writeln!(
out,
"Quality target: {}{fate}; metric {}; aggregation {}; band {} {}; scenario {}; environment {}\n",
code_span(kind),
code_span(&target.metric),
code_span(target.aggregation.as_str()),
code_span(&band),
code_span(&target.unit),
code_span(&target.scenario),
code_span(&target.environment),
);
}
pub(super) fn code_span(value: &str) -> String {
let longest = value.split(|c| c != '`').map(str::len).max().unwrap_or(0);
let fence = "`".repeat(longest + 1);
format!("{fence}{value}{fence}")
}
pub(super) fn escape_inline(value: &str) -> String {
let mut out = String::with_capacity(value.len());
let mut copied = 0;
for span in crate::text::code_spans(value) {
out.push_str(&escape_prose(&value[copied..span.start]));
out.push_str(&escape_code(&value[span.clone()]));
copied = span.end;
}
out.push_str(&escape_prose(&value[copied..]));
out
}
fn escape_prose(value: &str) -> String {
value
.replace('\\', "\\\\")
.replace(['\n', '\r'], " ")
.replace('|', "\\|")
.replace('*', "\\*")
.replace('_', "\\_")
.replace('[', "\\[")
.replace(']', "\\]")
.replace('#', "\\#")
}
fn escape_code(value: &str) -> String {
value.replace(['\n', '\r'], " ").replace('|', "\\|")
}