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