Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions LANG.md
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,8 @@ A call to a **disclosed** callee expands inline: the callee becomes the active l

In a chained expression, each call is its own message, emitted left-to-right; field accesses between calls are local. A `self.` call renders as a self-message.

Each lifeline head card shows the participant's C4 kind and name. A `container` or `component` participant SHOULD also show its `for` ancestry (enclosing node names, outermost first) dimmed beneath the name. Every declared participant SHOULD show its `///` summary, as on a C4 card (§9.1). A synthesised initiator carries neither.

### 9.3 Documentation site (`pds doc`)
`pds doc` generates a static documentation site from the workspace rooted at `pds.toml` (§8.1), analogous to `cargo doc`: every module and node is documented automatically, with diagrams (§9.1, §9.2) embedded on the relevant pages.

Expand Down
72 changes: 70 additions & 2 deletions crates/pseudoscript-emit/src/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -477,7 +477,8 @@ fn project_sequence(graph: &Graph, entry: &str) -> Result<SequenceScene, EmitErr
let participants = order
.into_iter()
.map(|fqn| {
let kind = graph.node(&fqn).map_or_else(
let node = graph.node(&fqn);
let kind = node.map_or_else(
|| {
if is_initiator(&fqn) {
NodeKind::Person
Expand All @@ -487,7 +488,14 @@ fn project_sequence(graph: &Graph, entry: &str) -> Result<SequenceScene, EmitErr
},
|n| n.kind,
);
Lifeline { fqn, kind }
let summary = node.and_then(|n| n.doc.summary.clone());
let parent_path = node.and_then(|n| ancestry_path(graph, n));
Lifeline {
fqn,
kind,
summary,
parent_path,
}
})
.collect();

Expand Down Expand Up @@ -541,10 +549,14 @@ fn project_black_box(graph: &Graph, node: &GraphNode, entry: &str) -> SequenceSc
Lifeline {
fqn: actor,
kind: NodeKind::Person,
summary: None,
parent_path: None,
},
Lifeline {
fqn: owner.clone(),
kind: graph.node(&owner).map_or(NodeKind::Container, |n| n.kind),
summary: graph.node(&owner).and_then(|n| n.doc.summary.clone()),
parent_path: graph.node(&owner).and_then(|n| ancestry_path(graph, n)),
},
],
items,
Expand Down Expand Up @@ -701,6 +713,33 @@ fn is_initiator(token: &str) -> bool {
token.starts_with("event:") || matches!(token, "scheduler" | "client" | "caller")
}

/// The structural ancestry shown dimmed under a container/component lifeline:
/// the enclosing node names, outermost first, joined with `::`. Derived by
/// walking the graph's `parent` chain (the FQN is module-flat and does not carry
/// the C4 nesting). `None` for other kinds and for a top-level node.
fn ancestry_path(graph: &Graph, node: &GraphNode) -> Option<String> {
if node.kind != NodeKind::Container && node.kind != NodeKind::Component {
return None;
}
let mut names = Vec::new();
let mut seen = std::collections::HashSet::new();
let mut cur = node.parent.as_deref();
// `seen` guards against a malformed `for` cycle so a bad graph can't hang the
// renderer.
while let Some(parent) = cur
.filter(|fqn| seen.insert(*fqn))
.and_then(|fqn| graph.node(fqn))
{
names.push(parent.name.clone());
cur = parent.parent.as_deref();
}
if names.is_empty() {
return None;
}
names.reverse();
Some(names.join("::"))
}

/// A call's type detail: `(name: ty, …): Ret`, the return type omitted when
/// `void`. Shown dimmed after the method name on a call message (`LANG.md` §9.2).
fn call_detail(sig: &Signature) -> String {
Expand Down Expand Up @@ -897,4 +936,33 @@ mod tests {
assert_eq!(node.boundary, None);
}
}

#[test]
fn sequence_lifeline_carries_for_ancestry_and_summary() {
let m = WorkspaceModule::new(
"m".to_owned(),
"//! m\npublic system Shop;\npublic container Api for Shop;\n\
/// Validates orders.\npublic component Validator for m::Api {\n \
#[http]\n public Check(): void { self.Help() }\n Help(): void;\n}"
.to_owned(),
);
let Scene::Sequence(seq) = project(
&graph(&[m]),
View::Sequence {
entry: "m::Validator::Check".to_owned(),
},
)
.expect("projects") else {
panic!("expected a sequence scene");
};
let v = seq
.participants
.iter()
.find(|p| p.fqn == "m::Validator")
.expect("validator lifeline present");
// The `for` ancestry (system::container), outermost first — not the
// module-flat FQN.
assert_eq!(v.parent_path.as_deref(), Some("Shop::Api"));
assert_eq!(v.summary.as_deref(), Some("Validates orders."));
}
}
52 changes: 52 additions & 0 deletions crates/pseudoscript-emit/src/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ const NODE_H: i32 = 60;
const NODE_GAP: i32 = 30;
const BOUNDARY_PAD: i32 = 30;
const ACT_W: i32 = 10; // execution-activation bar width (matches sequence::Metrics::act_w)
// Lifeline-card text inset: left rule + text pad, matching `draw_card`'s `tx` and
// the layout's `head::TEXT_INSET` so the parent path / summary align with the
// name above them.
const CARD_TEXT_X: i32 = 19;

// --- C4 layout --------------------------------------------------------------

Expand Down Expand Up @@ -105,6 +109,8 @@ fn to_diagram(scene: &SequenceScene) -> sequence::Diagram {
id: l.fqn.clone(),
label: simple_name(&l.fqn).to_owned(),
kind: kind_token(l.kind).to_owned(),
summary: l.summary.clone(),
parent_path: l.parent_path.clone(),
})
.collect(),
items: to_items(&scene.items),
Expand Down Expand Up @@ -402,6 +408,34 @@ fn render_sequence(scene: &SequenceScene) -> String {
&placed.label,
None,
);
// Dimmed parent path (container/component) then the wrapped summary,
// under the name. Baselines come from `sequence::head` so they line up
// with the card height the engine computed.
let tx = placed.card.x + CARD_TEXT_X;
if let Some(parent) = &placed.parent_path {
let _ = write!(
&mut out,
"<text x=\"{tx}\" y=\"{y}\" font-size=\"11\" fill=\"{SEQ_MUTED}\">{parent}</text>",
y = placed.card.y + sequence::head::PARENT_Y,
parent = escape_xml(parent),
);
}
let desc_top = sequence::head::DESC_TOP_Y
+ if placed.parent_path.is_some() {
sequence::head::DESC_SHIFT_Y
} else {
0
};
for (i, line) in placed.summary_lines.iter().enumerate() {
let _ = write!(
&mut out,
"<text x=\"{tx}\" y=\"{y}\" font-size=\"11.5\" fill=\"{SEQ_MUTED}\">{line}</text>",
y = placed.card.y
+ desc_top
+ i32::try_from(i).unwrap_or(0) * sequence::head::DESC_LINE_H,
line = escape_xml(line),
);
}
let _ = write!(
&mut out,
"<line x1=\"{x}\" y1=\"{top}\" x2=\"{x}\" y2=\"{bot}\" stroke=\"{SEQ_LINE}\" \
Expand Down Expand Up @@ -683,4 +717,22 @@ mod tests {
assert_eq!(simple_name("a::b::C"), "C");
assert_eq!(simple_name("event:a::B"), "event:a::B");
}

#[test]
fn sequence_head_card_draws_parent_path_and_summary() {
use crate::scene::{Lifeline, SequenceScene};
let scene = SequenceScene {
entry: "m::Comp::run".to_owned(),
participants: vec![Lifeline {
fqn: "m::Comp".to_owned(),
kind: NodeKind::Component,
summary: Some("Validates the order before queueing.".to_owned()),
parent_path: Some("Shop::Api".to_owned()),
}],
items: Vec::new(),
};
let svg = render_sequence(&scene);
assert!(svg.contains("Shop::Api"), "parent path drawn");
assert!(svg.contains("Validates the order"), "summary drawn");
}
}
10 changes: 10 additions & 0 deletions crates/pseudoscript-emit/src/scene.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,16 @@ pub struct Lifeline {
pub fqn: String,
/// The participant node's C4 kind, for the lifeline-head card styling.
pub kind: NodeKind,
/// The node's `///` summary, shown dimmed under the name (like a C4 card).
/// `None` for synthesised initiators and unresolved targets.
#[serde(default)]
pub summary: Option<String>,
/// The structural ancestry shown dimmed under a container/component name
/// (enclosing node names, outermost first, joined with `::`). The FQN is
/// module-flat, so this is derived from the graph, not the FQN. `None` for
/// other kinds and top-level nodes.
#[serde(default)]
pub parent_path: Option<String>,
}

/// One ordered item in a sequence trace: a message or a frame.
Expand Down
11 changes: 9 additions & 2 deletions crates/pseudoscript-layout/src/sequence/diagram.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,20 @@ pub struct Diagram {
pub items: Vec<Item>,
}

/// A lifeline: a stable `id` the messages reference, a display `label`, and a
/// `kind` token (the C4 kind, used only for head-card styling).
/// A lifeline: a stable `id` the messages reference, a display `label`, a
/// `kind` token (the C4 kind, used only for head-card styling), an optional
/// `///` summary, and the dimmed parent path — both shown under the name. The
/// projection supplies `parent_path` (the FQN is module-flat, so the engine
/// cannot derive the C4 ancestry itself).
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct Participant {
pub id: String,
pub label: String,
pub kind: String,
#[serde(default)]
pub summary: Option<String>,
#[serde(default)]
pub parent_path: Option<String>,
}

/// One ordered element: a message or a nestable fragment.
Expand Down
8 changes: 8 additions & 0 deletions crates/pseudoscript-layout/src/sequence/layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@ pub struct PlacedParticipant {
pub id: String,
pub label: String,
pub kind: String,
/// The ancestry shown dimmed under the name (the FQN minus its last segment),
/// for container/component lifelines only; `None` otherwise.
#[serde(default)]
pub parent_path: Option<String>,
/// The node's summary, wrapped to the card width and capped, drawn dimmed
/// under the name like a C4 card. Empty when the node has no summary.
#[serde(default)]
pub summary_lines: Vec<String>,
/// The head card rectangle.
pub card: Rect,
/// The lifeline's centre x (the dashed line and activations sit here).
Expand Down
Loading
Loading