1use std::collections::{BTreeMap, BTreeSet};
2
3use serde::{Deserialize, Serialize};
4use serde_json::{Value, json};
5
6use crate::ast::{AssignPathStep, Declaration, Expr, LabelMetadata, Program, ResourceRefExpr};
7use crate::lexer::Span;
8use crate::tracking::{
9 LashlangAstPath, LashlangExecutionContext, LashlangExecutionSiteBuilder, ProcessBranchSelection,
10};
11use crate::{LinkedModule, ModuleArtifact, ModuleRef, ProcessRef};
12
13pub fn static_graph_json(program: &Program, module_ref: impl Into<String>) -> Value {
14 static_graph_for_program(program, module_ref.into(), &BTreeMap::new())
15}
16
17pub fn linked_static_graph_json(linked: &LinkedModule) -> Value {
18 static_graph_for_program(
19 linked.program(),
20 linked.module_ref.to_string(),
21 &linked.artifact.exports.processes,
22 )
23}
24
25pub(crate) fn static_graph_json_for_module_ref(
26 module_ref: ModuleRef,
27 process_refs: &BTreeMap<String, ProcessRef>,
28) -> Value {
29 json!({
30 "module_ref": module_ref,
31 "processes": process_refs,
32 "nodes": [],
33 "edges": [],
34 })
35}
36
37pub(crate) fn static_graph_json_without_ir(module_ref: impl Into<String>) -> Value {
38 json!({
39 "module_ref": module_ref.into(),
40 "nodes": [],
41 "edges": [],
42 })
43}
44
45fn static_graph_for_program(
46 program: &Program,
47 module_ref: String,
48 process_refs: &BTreeMap<String, ProcessRef>,
49) -> Value {
50 let mut nodes = Vec::new();
51 let mut edges = Vec::new();
52
53 for (index, declaration) in program.declarations.iter().enumerate() {
54 let span = program.declaration_spans.get(index).copied();
55 match declaration {
56 Declaration::Process(process) => {
57 let process_id = process_refs
58 .get(process.name.as_str())
59 .map(process_node_id)
60 .unwrap_or_else(|| format!("process:{}", process.name));
61 nodes.push(node(&process_id, "process", process.name.as_str(), span));
62 collect_expr_graph(
63 &process.body,
64 &process_id,
65 span,
66 process_refs,
67 &mut nodes,
68 &mut edges,
69 );
70 }
71 Declaration::Type(type_decl) => {
72 nodes.push(node(
73 format!("type:{}", type_decl.name),
74 "type",
75 type_decl.name.as_str(),
76 span,
77 ));
78 }
79 }
80 }
81
82 let main_span = program
83 .expression_spans
84 .first()
85 .copied()
86 .or_else(|| program.declaration_spans.last().copied());
87 collect_expr_graph(
88 &program.main,
89 "main",
90 main_span,
91 process_refs,
92 &mut nodes,
93 &mut edges,
94 );
95
96 json!({
97 "module_ref": module_ref,
98 "nodes": nodes,
99 "edges": edges,
100 })
101}
102
103fn collect_expr_graph(
104 expr: &Expr,
105 owner: &str,
106 span: Option<Span>,
107 process_refs: &BTreeMap<String, ProcessRef>,
108 nodes: &mut Vec<Value>,
109 edges: &mut Vec<Value>,
110) {
111 match expr {
112 Expr::StartProcess(start) => {
113 let target = process_refs
114 .get(start.process.as_str())
115 .map(process_node_id)
116 .unwrap_or_else(|| format!("process:{}", start.process));
117 edges.push(edge(owner, &target, "starts", span));
118 for child in expr.children() {
119 collect_expr_graph(child, owner, span, process_refs, nodes, edges);
120 }
121 }
122 Expr::SleepFor(_) => {
123 let sleep_id = format!("{owner}:sleep:{}", nodes.len());
124 nodes.push(node(&sleep_id, "sleep", "sleep for", span));
125 edges.push(edge(owner, &sleep_id, "sleeps", span));
126 for child in expr.children() {
127 collect_expr_graph(child, &sleep_id, span, process_refs, nodes, edges);
128 }
129 }
130 Expr::SleepUntil(_) => {
131 let sleep_id = format!("{owner}:sleep:{}", nodes.len());
132 nodes.push(node(&sleep_id, "sleep", "sleep until", span));
133 edges.push(edge(owner, &sleep_id, "sleeps", span));
134 for child in expr.children() {
135 collect_expr_graph(child, &sleep_id, span, process_refs, nodes, edges);
136 }
137 }
138 Expr::WaitSignal => {
139 let wait_id = format!("{owner}:wait:{}", nodes.len());
140 nodes.push(node(&wait_id, "wait", "wait signal", span));
141 edges.push(edge(owner, &wait_id, "waits", span));
142 }
143 Expr::SignalRun { .. } => {
144 let signal_id = format!("{owner}:signal:{}", nodes.len());
145 nodes.push(node(&signal_id, "signal", "signal run", span));
146 edges.push(edge(owner, &signal_id, "signals", span));
147 for child in expr.children() {
148 collect_expr_graph(child, &signal_id, span, process_refs, nodes, edges);
149 }
150 }
151 Expr::ReceiverCall { operation, .. } => {
152 let op_id = format!("{owner}:op:{operation}:{}", nodes.len());
153 nodes.push(node(&op_id, "resource_operation", operation.as_str(), span));
154 edges.push(edge(owner, &op_id, "calls", span));
155 for child in expr.children() {
156 collect_expr_graph(child, owner, span, process_refs, nodes, edges);
157 }
158 }
159 Expr::ResourceRef(resource) => {
160 let resource_id = resource_node_id(resource);
161 nodes.push(node(&resource_id, "resource", resource.path_string(), span));
162 edges.push(edge(owner, resource_id, "uses", span));
163 }
164 Expr::If {
165 condition,
166 then_block,
167 else_block,
168 } => {
169 let branch_id = format!("{owner}:branch:{}", nodes.len());
170 nodes.push(node(&branch_id, "branch", "if", span));
171 edges.push(edge(owner, &branch_id, "branches", span));
172 collect_expr_graph(condition, &branch_id, span, process_refs, nodes, edges);
173 collect_expr_graph(then_block, &branch_id, span, process_refs, nodes, edges);
174 collect_expr_graph(else_block, &branch_id, span, process_refs, nodes, edges);
175 }
176 Expr::Finish(expr) | Expr::Submit(expr) => {
177 let terminal_id = format!("{owner}:terminal:{}", nodes.len());
178 nodes.push(node(&terminal_id, "terminal", "result", span));
179 edges.push(edge(owner, terminal_id, "terminates", span));
180 if let Some(expr) = expr {
181 collect_expr_graph(expr, owner, span, process_refs, nodes, edges);
182 }
183 }
184 _ => {
185 for child in expr.children() {
186 collect_expr_graph(child, owner, span, process_refs, nodes, edges);
187 }
188 }
189 }
190}
191
192fn node(
193 id: impl Into<String>,
194 kind: &'static str,
195 label: impl Into<String>,
196 span: Option<Span>,
197) -> Value {
198 json!({
199 "id": id.into(),
200 "kind": kind,
201 "label": label.into(),
202 "span": span_value(span),
203 })
204}
205
206fn edge(
207 from: impl Into<String>,
208 to: impl Into<String>,
209 label: impl Into<String>,
210 span: Option<Span>,
211) -> Value {
212 json!({
213 "from": from.into(),
214 "to": to.into(),
215 "label": label.into(),
216 "span": span_value(span),
217 })
218}
219
220fn span_value(span: Option<Span>) -> Value {
221 let span = span.unwrap_or(Span { start: 0, end: 1 });
222 let end = if span.end > span.start {
223 span.end
224 } else {
225 span.start + 1
226 };
227 json!({ "start": span.start, "end": end })
228}
229
230fn resource_node_id(resource: &ResourceRefExpr) -> String {
231 format!("resource:{}", resource.path_string())
232}
233
234fn process_node_id(process_ref: &ProcessRef) -> String {
235 format!("process:{}:{}", process_ref.component, process_ref.pos)
236}
237
238#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
239pub struct LashlangMapOptions {
240 #[serde(default)]
241 pub include_reachable_processes: bool,
242}
243
244#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
245pub struct LashlangMap {
246 pub module_ref: ModuleRef,
247 pub entry_kind: String,
248 #[serde(default, skip_serializing_if = "Option::is_none")]
249 pub entry_ref: Option<ProcessRef>,
250 pub entry_name: String,
251 #[serde(default)]
252 pub nodes: Vec<LashlangMapNode>,
253 #[serde(default)]
254 pub edges: Vec<LashlangMapEdge>,
255}
256
257#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
258pub struct LashlangMapNode {
259 pub id: String,
260 pub kind: String,
261 pub label: String,
262 #[serde(default, skip_serializing_if = "Option::is_none")]
263 pub label_metadata: Option<LabelMetadata>,
264}
265
266#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
267pub struct LashlangMapEdge {
268 pub id: String,
269 pub from: String,
270 pub to: String,
271 pub label: String,
272}
273
274pub fn map_lashlang_process(
275 artifact: &ModuleArtifact,
276 process_ref: &ProcessRef,
277 options: LashlangMapOptions,
278) -> Option<LashlangMap> {
279 let process_name = artifact.process_name_for_ref(process_ref)?;
280 let mut builder = LashlangMapBuilder {
281 artifact,
282 options,
283 nodes: Vec::new(),
284 edges: Vec::new(),
285 visited_processes: BTreeSet::new(),
286 };
287 builder.visit_process(process_name, LashlangAstPath::root());
288 Some(LashlangMap {
289 module_ref: artifact.module_ref.clone(),
290 entry_kind: "process".to_string(),
291 entry_ref: Some(process_ref.clone()),
292 entry_name: process_name.to_string(),
293 nodes: builder.nodes,
294 edges: builder.edges,
295 })
296}
297
298pub fn map_lashlang_main(artifact: &ModuleArtifact, options: LashlangMapOptions) -> LashlangMap {
299 let mut builder = LashlangMapBuilder {
300 artifact,
301 options,
302 nodes: Vec::new(),
303 edges: Vec::new(),
304 visited_processes: BTreeSet::new(),
305 };
306 builder.visit_main();
307 LashlangMap {
308 module_ref: artifact.module_ref.clone(),
309 entry_kind: "main".to_string(),
310 entry_ref: None,
311 entry_name: "main".to_string(),
312 nodes: builder.nodes,
313 edges: builder.edges,
314 }
315}
316
317struct LashlangMapBuilder<'artifact> {
318 artifact: &'artifact ModuleArtifact,
319 options: LashlangMapOptions,
320 nodes: Vec<LashlangMapNode>,
321 edges: Vec<LashlangMapEdge>,
322 visited_processes: BTreeSet<String>,
323}
324
325impl LashlangMapBuilder<'_> {
326 fn tracking_context(&self, process_name: &str) -> Option<LashlangExecutionContext> {
327 let process_ref = self.artifact.process_ref(process_name)?.clone();
328 Some(LashlangExecutionContext::process(
329 self.artifact.module_ref.clone(),
330 process_ref,
331 process_name,
332 ))
333 }
334
335 fn visit_main(&mut self) {
336 let context = LashlangExecutionContext::main(self.artifact.module_ref.clone());
337 let site_builder = context.builder();
338 let main_id = site_builder.main_node_id();
339 self.node(&main_id, "main", "main", None);
340 self.visit_expr(
341 &self.artifact.canonical_ir.main,
342 &context,
343 std::slice::from_ref(&main_id),
344 LashlangAstPath::root(),
345 );
346 }
347
348 fn visit_process(&mut self, process_name: &str, path: LashlangAstPath) {
349 if !self.visited_processes.insert(process_name.to_string()) {
350 return;
351 }
352 let Some(context) = self.tracking_context(process_name) else {
353 return;
354 };
355 let Some(process) = self.artifact.canonical_ir.process(process_name) else {
356 return;
357 };
358 let site_builder = context.builder();
359 let process_id = site_builder.process_node_id();
360 self.node(&process_id, "process", process_name, process.label.clone());
361 self.visit_expr(
362 &process.body,
363 &context,
364 std::slice::from_ref(&process_id),
365 path,
366 );
367 }
368
369 fn visit_expr(
370 &mut self,
371 expr: &Expr,
372 context: &LashlangExecutionContext,
373 owners: &[String],
374 path: LashlangAstPath,
375 ) -> Vec<String> {
376 self.visit_expr_with_label_metadata(expr, context, owners, path, None)
377 }
378
379 fn visit_expr_with_label_metadata(
380 &mut self,
381 expr: &Expr,
382 context: &LashlangExecutionContext,
383 owners: &[String],
384 path: LashlangAstPath,
385 label_metadata: Option<&LabelMetadata>,
386 ) -> Vec<String> {
387 let site_builder = context.builder();
388 match expr {
389 Expr::LabelAnnotated { label, expr } => {
390 if label_attaches_to_concrete_node(expr) {
391 self.visit_expr_with_label_metadata(expr, context, owners, path, Some(label))
392 } else {
393 let site = site_builder.node_site(&path, "step", label.title.as_str());
394 self.node(
395 &site.node_id,
396 &site.node_kind,
397 &site.label,
398 Some(label.clone()),
399 );
400 self.edges_from_owners(&site_builder, &path, owners, &site.node_id, "steps");
401 for (index, child) in expr.children().enumerate() {
402 self.visit_expr(
403 child,
404 context,
405 std::slice::from_ref(&site.node_id),
406 path.child(index),
407 );
408 }
409 vec![site.node_id]
410 }
411 }
412 Expr::Block(expressions) => {
413 let mut next_owners = owners.to_vec();
414 for (index, expression) in expressions.iter().enumerate() {
415 next_owners =
416 self.visit_expr(expression, context, &next_owners, path.child(index));
417 }
418 next_owners
419 }
420 Expr::Assign { target, expr } if label_metadata.is_some() => {
421 let value_index = target
422 .steps
423 .iter()
424 .filter(|step| matches!(step, AssignPathStep::Index(_)))
425 .count();
426 self.visit_expr_with_label_metadata(
427 expr,
428 context,
429 owners,
430 path.child(value_index),
431 label_metadata,
432 )
433 }
434 Expr::Await(expr) | Expr::ResultUnwrap(expr) if label_metadata.is_some() => self
435 .visit_expr_with_label_metadata(
436 expr,
437 context,
438 owners,
439 path.child(0),
440 label_metadata,
441 ),
442 Expr::StartProcess(start) => {
443 let site = site_builder.node_site(
444 &path,
445 "child_process",
446 format!("start {}", start.process),
447 );
448 self.node(
449 &site.node_id,
450 &site.node_kind,
451 &site.label,
452 label_metadata.cloned(),
453 );
454 self.edges_from_owners(&site_builder, &path, owners, &site.node_id, "starts");
455 let target = self
456 .tracking_context(start.process.as_str())
457 .map(|context| context.builder().process_node_id())
458 .unwrap_or_else(|| format!("process:{}", start.process));
459 self.edge_with_id(
460 site_builder.edge_id(&path, &site.node_id, &target, "child"),
461 &site.node_id,
462 &target,
463 "child",
464 );
465 if self.options.include_reachable_processes {
466 self.visit_process(start.process.as_str(), LashlangAstPath::root());
467 }
468 for (index, child) in expr.children().enumerate() {
469 self.visit_expr(
470 child,
471 context,
472 std::slice::from_ref(&site.node_id),
473 path.child(index),
474 );
475 }
476 vec![site.node_id]
477 }
478 Expr::ReceiverCall { operation, .. } => {
479 let site = site_builder.node_site(&path, "resource_operation", operation.as_str());
480 self.node(
481 &site.node_id,
482 &site.node_kind,
483 &site.label,
484 label_metadata.cloned(),
485 );
486 self.edges_from_owners(&site_builder, &path, owners, &site.node_id, "calls");
487 for (index, child) in expr.children().enumerate() {
488 self.visit_expr(
489 child,
490 context,
491 std::slice::from_ref(&site.node_id),
492 path.child(index),
493 );
494 }
495 vec![site.node_id]
496 }
497 Expr::SleepFor(_) => {
498 let site = site_builder.node_site(&path, "sleep", "sleep for");
499 self.node(
500 &site.node_id,
501 &site.node_kind,
502 &site.label,
503 label_metadata.cloned(),
504 );
505 self.edges_from_owners(&site_builder, &path, owners, &site.node_id, "sleeps");
506 for (index, child) in expr.children().enumerate() {
507 self.visit_expr(
508 child,
509 context,
510 std::slice::from_ref(&site.node_id),
511 path.child(index),
512 );
513 }
514 vec![site.node_id]
515 }
516 Expr::SleepUntil(_) => {
517 let site = site_builder.node_site(&path, "sleep", "sleep until");
518 self.node(
519 &site.node_id,
520 &site.node_kind,
521 &site.label,
522 label_metadata.cloned(),
523 );
524 self.edges_from_owners(&site_builder, &path, owners, &site.node_id, "sleeps");
525 for (index, child) in expr.children().enumerate() {
526 self.visit_expr(
527 child,
528 context,
529 std::slice::from_ref(&site.node_id),
530 path.child(index),
531 );
532 }
533 vec![site.node_id]
534 }
535 Expr::WaitSignal => {
536 let site = site_builder.node_site(&path, "wait", "wait signal");
537 self.node(
538 &site.node_id,
539 &site.node_kind,
540 &site.label,
541 label_metadata.cloned(),
542 );
543 self.edges_from_owners(&site_builder, &path, owners, &site.node_id, "waits");
544 vec![site.node_id]
545 }
546 Expr::SignalRun { .. } => {
547 let site = site_builder.node_site(&path, "signal", "signal run");
548 self.node(
549 &site.node_id,
550 &site.node_kind,
551 &site.label,
552 label_metadata.cloned(),
553 );
554 self.edges_from_owners(&site_builder, &path, owners, &site.node_id, "signals");
555 for (index, child) in expr.children().enumerate() {
556 self.visit_expr(
557 child,
558 context,
559 std::slice::from_ref(&site.node_id),
560 path.child(index),
561 );
562 }
563 vec![site.node_id]
564 }
565 Expr::Finish(value) | Expr::Submit(value) => {
566 let site = site_builder.node_site(&path, "terminal", "result");
567 self.node(
568 &site.node_id,
569 &site.node_kind,
570 &site.label,
571 label_metadata.cloned(),
572 );
573 self.edges_from_owners(&site_builder, &path, owners, &site.node_id, "terminates");
574 if let Some(value) = value {
575 self.visit_expr(
576 value,
577 context,
578 std::slice::from_ref(&site.node_id),
579 path.child(0),
580 );
581 }
582 vec![site.node_id]
583 }
584 Expr::Fail(value) => {
585 let site = site_builder.node_site(&path, "terminal", "failure");
586 self.node(
587 &site.node_id,
588 &site.node_kind,
589 &site.label,
590 label_metadata.cloned(),
591 );
592 self.edges_from_owners(&site_builder, &path, owners, &site.node_id, "terminates");
593 self.visit_expr(
594 value,
595 context,
596 std::slice::from_ref(&site.node_id),
597 path.child(0),
598 );
599 vec![site.node_id]
600 }
601 Expr::ResourceRef(resource) => {
602 let resource_id = resource_node_id(resource);
603 self.node(&resource_id, "resource", &resource.path_string(), None);
604 self.edges_from_owners(&site_builder, &path, owners, &resource_id, "uses");
605 owners.to_vec()
606 }
607 Expr::If {
608 condition,
609 then_block,
610 else_block,
611 } => {
612 let site = site_builder.branch_site(&path);
613 self.node(
614 &site.node_id,
615 &site.node_kind,
616 &site.label,
617 label_metadata.cloned(),
618 );
619 self.edges_from_owners(&site_builder, &path, owners, &site.node_id, "branches");
620 self.visit_expr(
621 condition,
622 context,
623 std::slice::from_ref(&site.node_id),
624 path.child(0),
625 );
626 let then_path = path.child(1);
627 let else_path = path.child(2);
628 let then_id =
629 site_builder.branch_arm_node_id(&then_path, ProcessBranchSelection::Then);
630 let else_id =
631 site_builder.branch_arm_node_id(&else_path, ProcessBranchSelection::Else);
632 self.node(&then_id, "branch_arm", "then", None);
633 self.node(&else_id, "branch_arm", "else", None);
634 if let Some(branch) = &site.branch {
635 self.edge_with_id(branch.then_edge_id.clone(), &site.node_id, &then_id, "then");
636 self.edge_with_id(branch.else_edge_id.clone(), &site.node_id, &else_id, "else");
637 }
638 let then_continuations = self.visit_expr(
639 then_block,
640 context,
641 std::slice::from_ref(&then_id),
642 then_path,
643 );
644 let else_continuations = self.visit_expr(
645 else_block,
646 context,
647 std::slice::from_ref(&else_id),
648 else_path,
649 );
650 let mut continuations = Vec::new();
651 extend_unique_owners(&mut continuations, then_continuations);
652 extend_unique_owners(&mut continuations, else_continuations);
653 continuations
654 }
655 Expr::Yield(_) => {
656 let site = site_builder.node_site(&path, "process_event", "yield");
657 self.node(
658 &site.node_id,
659 &site.node_kind,
660 &site.label,
661 label_metadata.cloned(),
662 );
663 self.edges_from_owners(&site_builder, &path, owners, &site.node_id, "emits");
664 for (index, child) in expr.children().enumerate() {
665 self.visit_expr(
666 child,
667 context,
668 std::slice::from_ref(&site.node_id),
669 path.child(index),
670 );
671 }
672 vec![site.node_id]
673 }
674 Expr::Wake(_) => {
675 let site = site_builder.node_site(&path, "process_event", "wake");
676 self.node(
677 &site.node_id,
678 &site.node_kind,
679 &site.label,
680 label_metadata.cloned(),
681 );
682 self.edges_from_owners(&site_builder, &path, owners, &site.node_id, "emits");
683 for (index, child) in expr.children().enumerate() {
684 self.visit_expr(
685 child,
686 context,
687 std::slice::from_ref(&site.node_id),
688 path.child(index),
689 );
690 }
691 vec![site.node_id]
692 }
693 _ => {
694 let mut next_owners = owners.to_vec();
695 for (index, child) in expr.children().enumerate() {
696 next_owners = self.visit_expr(child, context, &next_owners, path.child(index));
697 }
698 next_owners
699 }
700 }
701 }
702
703 fn node(&mut self, id: &str, kind: &str, label: &str, label_metadata: Option<LabelMetadata>) {
704 if let Some(node) = self.nodes.iter_mut().find(|node| node.id == id) {
705 if node.label_metadata.is_none() && label_metadata.is_some() {
706 node.label_metadata = label_metadata;
707 }
708 return;
709 }
710 self.nodes.push(LashlangMapNode {
711 id: id.to_string(),
712 kind: kind.to_string(),
713 label: label.to_string(),
714 label_metadata,
715 });
716 }
717
718 fn edge_with_id(&mut self, id: String, from: &str, to: &str, label: &str) {
719 if self.edges.iter().any(|edge| edge.id == id) {
720 return;
721 }
722 self.edges.push(LashlangMapEdge {
723 id,
724 from: from.to_string(),
725 to: to.to_string(),
726 label: label.to_string(),
727 });
728 }
729
730 fn edges_from_owners(
731 &mut self,
732 site_builder: &LashlangExecutionSiteBuilder<'_>,
733 path: &LashlangAstPath,
734 owners: &[String],
735 to: &str,
736 label: &str,
737 ) {
738 for owner in owners {
739 self.edge_with_id(
740 site_builder.edge_id(path, owner, to, label),
741 owner,
742 to,
743 label,
744 );
745 }
746 }
747}
748
749fn extend_unique_owners(target: &mut Vec<String>, owners: impl IntoIterator<Item = String>) {
750 for owner in owners {
751 if !target.contains(&owner) {
752 target.push(owner);
753 }
754 }
755}
756
757fn label_attaches_to_concrete_node(expr: &Expr) -> bool {
758 match expr {
759 Expr::LabelAnnotated { .. } => false,
760 Expr::Assign { expr, .. } => label_attaches_to_assignment_value(expr),
761 Expr::Await(expr) | Expr::ResultUnwrap(expr) => label_attaches_to_concrete_node(expr),
762 Expr::ReceiverCall { .. }
763 | Expr::StartProcess(_)
764 | Expr::SleepFor(_)
765 | Expr::SleepUntil(_)
766 | Expr::WaitSignal
767 | Expr::SignalRun { .. }
768 | Expr::Submit(_)
769 | Expr::Yield(_)
770 | Expr::Wake(_)
771 | Expr::Finish(_)
772 | Expr::Fail(_)
773 | Expr::If { .. } => true,
774 Expr::Block(_)
775 | Expr::Null
776 | Expr::Bool(_)
777 | Expr::Number(_)
778 | Expr::String(_)
779 | Expr::Variable(_)
780 | Expr::List(_)
781 | Expr::Record(_)
782 | Expr::For { .. }
783 | Expr::While { .. }
784 | Expr::Break
785 | Expr::Continue
786 | Expr::ProcessRef { .. }
787 | Expr::HostValueConstructor { .. }
788 | Expr::ResourceRef(_)
789 | Expr::Cancel(_)
790 | Expr::Print(_)
791 | Expr::BuiltinCall { .. }
792 | Expr::Field { .. }
793 | Expr::Index { .. }
794 | Expr::Unary { .. }
795 | Expr::Binary { .. }
796 | Expr::TypeLiteral(_) => false,
797 }
798}
799
800fn label_attaches_to_assignment_value(expr: &Expr) -> bool {
801 match expr {
802 Expr::Await(expr) | Expr::ResultUnwrap(expr) => label_attaches_to_assignment_value(expr),
803 Expr::ReceiverCall { .. }
804 | Expr::StartProcess(_)
805 | Expr::SleepFor(_)
806 | Expr::SleepUntil(_)
807 | Expr::WaitSignal
808 | Expr::SignalRun { .. }
809 | Expr::Submit(_)
810 | Expr::Yield(_)
811 | Expr::Wake(_)
812 | Expr::Finish(_)
813 | Expr::Fail(_)
814 | Expr::If { .. } => true,
815 _ => false,
816 }
817}
818
819#[cfg(test)]
820mod tests {
821 use super::*;
822
823 fn linked(source: &str) -> crate::LinkedModule {
824 let mut resources = crate::ResourceCatalog::new();
825 resources.add_module_operation(
826 ["tools"],
827 "Tools",
828 "read_file",
829 "read_file",
830 crate::TypeExpr::Any,
831 crate::TypeExpr::Any,
832 );
833 crate::LinkedModule::link(
834 crate::parse(source).expect("parse module"),
835 crate::LashlangSurface::new(resources, crate::LashlangAbilities::all())
836 .with_language_features(
837 crate::LashlangLanguageFeatures::default().with_label_annotations(),
838 ),
839 )
840 .expect("link module")
841 }
842
843 #[test]
844 fn process_map_uses_stable_refs_and_handles_cycles() {
845 let linked = linked(
846 r#"
847 process scan(tool: Tools) {
848 start scan(tool: tool)
849 text = await tool.read_file({ path: "." })?
850 finish text
851 }
852 "#,
853 );
854 let process_ref = linked
855 .artifact
856 .process_ref("scan")
857 .expect("scan process ref")
858 .clone();
859
860 let map = map_lashlang_process(
861 &linked.artifact,
862 &process_ref,
863 LashlangMapOptions {
864 include_reachable_processes: true,
865 },
866 )
867 .expect("map process");
868
869 assert_eq!(map.module_ref, linked.module_ref);
870 assert_eq!(map.entry_ref.as_ref(), Some(&process_ref));
871 assert!(map.nodes.iter().any(|node| node.kind == "process"));
872 assert!(
873 map.nodes
874 .iter()
875 .any(|node| node.kind == "resource_operation")
876 );
877 assert!(map.edges.iter().any(|edge| edge.label == "starts"));
878 assert!(map.edges.iter().all(|edge| !edge.id.is_empty()));
879
880 let remapped = map_lashlang_process(
881 &linked.artifact,
882 &process_ref,
883 LashlangMapOptions {
884 include_reachable_processes: true,
885 },
886 )
887 .expect("remap process");
888 assert_eq!(map, remapped);
889 assert!(
890 map.nodes
891 .iter()
892 .any(|node| node.id.starts_with("resource_operation:")),
893 "resource operation node should use stable hashed identity: {map:?}"
894 );
895 }
896
897 #[test]
898 fn process_map_includes_label_metadata_on_visual_nodes() {
899 let linked = linked(
900 r#"
901 @label(title: "Scan files", description: "Process node")
902 process scan(tool: Tools, flag: bool) {
903 @label(title: "Read file", description: "Operation node")
904 text = await tool.read_file({ path: "." })?
905 @label(title: "Choose path")
906 if flag {
907 @label(title: "Wake agent")
908 wake text
909 } else {
910 @label(title: "Finish scan")
911 finish text
912 }
913 }
914 "#,
915 );
916 let process_ref = linked
917 .artifact
918 .process_ref("scan")
919 .expect("scan process ref")
920 .clone();
921
922 let map = map_lashlang_process(
923 &linked.artifact,
924 &process_ref,
925 LashlangMapOptions::default(),
926 )
927 .expect("map process");
928
929 assert_label_metadata(&map, "process", "Scan files", Some("Process node"));
930 assert_label_metadata(
931 &map,
932 "resource_operation",
933 "Read file",
934 Some("Operation node"),
935 );
936 assert_label_metadata(&map, "branch", "Choose path", None);
937 assert_label_metadata(&map, "process_event", "Wake agent", None);
938 assert_label_metadata(&map, "terminal", "Finish scan", None);
939 }
940
941 #[test]
942 fn main_map_includes_labeled_pure_setup_step_nodes() {
943 let linked = linked(
944 r#"
945 @label(title: "Prepare", description: "Pure setup")
946 value = 1
947 @label(title: "Return")
948 submit value
949 "#,
950 );
951
952 let map = map_lashlang_main(&linked.artifact, LashlangMapOptions::default());
953
954 assert_eq!(map.entry_kind, "main");
955 assert_eq!(map.entry_ref, None);
956 assert_eq!(map.entry_name, "main");
957 assert_label_metadata(&map, "step", "Prepare", Some("Pure setup"));
958 assert_label_metadata(&map, "terminal", "Return", None);
959 let main_id = node_id(&map, "main", "main");
960 let step_id = node_id_with_label_metadata(&map, "step", "Prepare");
961 let terminal_id = node_id_with_label_metadata(&map, "terminal", "Return");
962 assert_edge(&map, &main_id, &step_id, "steps");
963 assert_edge(&map, &step_id, &terminal_id, "terminates");
964 }
965
966 #[test]
967 fn process_map_chains_sequential_visual_statements() {
968 let linked = linked(
969 r#"
970 process on_button(event: any) {
971 @label(title: "Button Pressed")
972 wake event
973 @label(title: "Finish")
974 finish true
975 }
976 "#,
977 );
978 let process_ref = linked
979 .artifact
980 .process_ref("on_button")
981 .expect("on_button process ref")
982 .clone();
983
984 let map = map_lashlang_process(
985 &linked.artifact,
986 &process_ref,
987 LashlangMapOptions::default(),
988 )
989 .expect("map process");
990 let process_id = node_id(&map, "process", "on_button");
991 let wake_id = node_id_with_label_metadata(&map, "process_event", "Button Pressed");
992 let terminal_id = node_id_with_label_metadata(&map, "terminal", "Finish");
993
994 assert_edge(&map, &process_id, &wake_id, "emits");
995 assert_edge(&map, &wake_id, &terminal_id, "terminates");
996 assert!(
997 !map.edges
998 .iter()
999 .any(|edge| edge.from == process_id && edge.to == terminal_id),
1000 "terminal should follow wake instead of branching from process: {map:?}"
1001 );
1002 }
1003
1004 fn assert_label_metadata(
1005 map: &LashlangMap,
1006 kind: &str,
1007 title: &str,
1008 description: Option<&str>,
1009 ) {
1010 let node = map
1011 .nodes
1012 .iter()
1013 .find(|node| {
1014 node.kind == kind
1015 && node
1016 .label_metadata
1017 .as_ref()
1018 .is_some_and(|label| label.title.as_str() == title)
1019 })
1020 .unwrap_or_else(|| panic!("missing `{title}` {kind} node in {map:?}"));
1021 assert_eq!(
1022 node.label_metadata
1023 .as_ref()
1024 .and_then(|label| label.description.as_deref()),
1025 description
1026 );
1027 }
1028
1029 fn node_id(map: &LashlangMap, kind: &str, label: &str) -> String {
1030 map.nodes
1031 .iter()
1032 .find(|node| node.kind == kind && node.label == label)
1033 .unwrap_or_else(|| panic!("missing `{label}` {kind} node in {map:?}"))
1034 .id
1035 .clone()
1036 }
1037
1038 fn node_id_with_label_metadata(map: &LashlangMap, kind: &str, title: &str) -> String {
1039 map.nodes
1040 .iter()
1041 .find(|node| {
1042 node.kind == kind
1043 && node
1044 .label_metadata
1045 .as_ref()
1046 .is_some_and(|label| label.title.as_str() == title)
1047 })
1048 .unwrap_or_else(|| panic!("missing `{title}` {kind} node in {map:?}"))
1049 .id
1050 .clone()
1051 }
1052
1053 fn assert_edge(map: &LashlangMap, from: &str, to: &str, label: &str) {
1054 assert!(
1055 map.edges
1056 .iter()
1057 .any(|edge| edge.from == from && edge.to == to && edge.label == label),
1058 "missing `{label}` edge {from} -> {to} in {map:?}"
1059 );
1060 }
1061
1062 fn assert_no_edge(map: &LashlangMap, from: &str, to: &str, label: &str) {
1063 assert!(
1064 !map.edges
1065 .iter()
1066 .any(|edge| edge.from == from && edge.to == to && edge.label == label),
1067 "unexpected `{label}` edge {from} -> {to} in {map:?}"
1068 );
1069 }
1070
1071 #[test]
1072 fn process_map_joins_value_conditional_continuations_from_branch_arms() {
1073 let linked = linked(
1074 r#"
1075 process choose(tool: Tools, flag: bool) {
1076 topic = flag
1077 ? "red"
1078 : "blue"
1079
1080 @label(title: "Generate Queries")
1081 value = await tool.read_file({ path: topic })?
1082 finish value
1083 }
1084 "#,
1085 );
1086 let process_ref = linked
1087 .artifact
1088 .process_ref("choose")
1089 .expect("choose process ref")
1090 .clone();
1091
1092 let map = map_lashlang_process(
1093 &linked.artifact,
1094 &process_ref,
1095 LashlangMapOptions::default(),
1096 )
1097 .expect("map process");
1098 let branch_id = node_id(&map, "branch", "if");
1099 let then_id = node_id(&map, "branch_arm", "then");
1100 let else_id = node_id(&map, "branch_arm", "else");
1101 let operation_id =
1102 node_id_with_label_metadata(&map, "resource_operation", "Generate Queries");
1103
1104 assert_edge(&map, &then_id, &operation_id, "calls");
1105 assert_edge(&map, &else_id, &operation_id, "calls");
1106 assert_no_edge(&map, &branch_id, &operation_id, "calls");
1107 }
1108
1109 #[test]
1110 fn process_map_joins_block_conditional_continuations_from_branch_bodies() {
1111 let linked = linked(
1112 r#"
1113 process choose(flag: bool) {
1114 if flag {
1115 @label(title: "Then Wake")
1116 wake { path: "then" }
1117 } else {
1118 @label(title: "Else Wake")
1119 wake { path: "else" }
1120 }
1121
1122 @label(title: "Finish")
1123 finish true
1124 }
1125 "#,
1126 );
1127 let process_ref = linked
1128 .artifact
1129 .process_ref("choose")
1130 .expect("choose process ref")
1131 .clone();
1132
1133 let map = map_lashlang_process(
1134 &linked.artifact,
1135 &process_ref,
1136 LashlangMapOptions::default(),
1137 )
1138 .expect("map process");
1139 let branch_id = node_id(&map, "branch", "if");
1140 let then_wake_id = node_id_with_label_metadata(&map, "process_event", "Then Wake");
1141 let else_wake_id = node_id_with_label_metadata(&map, "process_event", "Else Wake");
1142 let terminal_id = node_id_with_label_metadata(&map, "terminal", "Finish");
1143
1144 assert_edge(&map, &then_wake_id, &terminal_id, "terminates");
1145 assert_edge(&map, &else_wake_id, &terminal_id, "terminates");
1146 assert_no_edge(&map, &branch_id, &terminal_id, "terminates");
1147 }
1148
1149 #[test]
1150 fn process_map_has_stable_branch_edges() {
1151 let linked_module = linked(
1152 r#"
1153 process choose(tool: Tools, flag: bool) {
1154 if flag {
1155 value = await tool.read_file({ path: "a" })?
1156 finish value
1157 } else {
1158 finish "none"
1159 }
1160 }
1161 "#,
1162 );
1163 let process_ref = linked_module
1164 .artifact
1165 .process_ref("choose")
1166 .expect("choose process ref")
1167 .clone();
1168
1169 let map = map_lashlang_process(
1170 &linked_module.artifact,
1171 &process_ref,
1172 LashlangMapOptions::default(),
1173 )
1174 .expect("map process");
1175
1176 let branch_edges = map
1177 .edges
1178 .iter()
1179 .filter(|edge| matches!(edge.label.as_str(), "then" | "else"))
1180 .collect::<Vec<_>>();
1181 assert_eq!(branch_edges.len(), 2);
1182 assert!(branch_edges.iter().all(|edge| !edge.id.is_empty()));
1183
1184 let reparsed = linked(
1185 r#"
1186 process choose(tool: Tools, flag: bool) {
1187 if flag {
1188 value = await tool.read_file({ path: "a" })?
1189 finish value
1190 } else {
1191 finish "none"
1192 }
1193 }
1194 "#,
1195 );
1196 let reparsed_ref = reparsed
1197 .artifact
1198 .process_ref("choose")
1199 .expect("choose process ref")
1200 .clone();
1201 let reparsed_map = map_lashlang_process(
1202 &reparsed.artifact,
1203 &reparsed_ref,
1204 LashlangMapOptions::default(),
1205 )
1206 .expect("reparsed map");
1207 assert_eq!(map.nodes, reparsed_map.nodes);
1208 assert_eq!(map.edges, reparsed_map.edges);
1209 }
1210}