diff --git a/.github/skills/score-architecture/SKILL.md b/.github/skills/score-architecture/SKILL.md index f1a3f1a3..20349756 100644 --- a/.github/skills/score-architecture/SKILL.md +++ b/.github/skills/score-architecture/SKILL.md @@ -268,7 +268,7 @@ participant "Unit 1" as unit_1 <> participant "Unit 2" as unit_2 <> unit_1 -> unit_2 : GetData() -unit_2 --> unit_1 : return : Data* +return Data* @enduml ``` diff --git a/bazel/rules/rules_score/docs/_assets/SeoocExample_DynamicDesign.puml b/bazel/rules/rules_score/docs/_assets/SeoocExample_DynamicDesign.puml index a220b160..89777c0c 100644 --- a/bazel/rules/rules_score/docs/_assets/SeoocExample_DynamicDesign.puml +++ b/bazel/rules/rules_score/docs/_assets/SeoocExample_DynamicDesign.puml @@ -17,6 +17,6 @@ participant "Unit 1" as unit_1 <> participant "Unit 2" as unit_2 <> unit_1 -> unit_2 : GetData() -unit_2 --> unit_1 : return : Data* +return Data* @enduml diff --git a/bazel/rules/rules_score/examples/seooc/design/dynamic_design.puml b/bazel/rules/rules_score/examples/seooc/design/dynamic_design.puml index 464f39ea..5fe8e442 100644 --- a/bazel/rules/rules_score/examples/seooc/design/dynamic_design.puml +++ b/bazel/rules/rules_score/examples/seooc/design/dynamic_design.puml @@ -17,6 +17,6 @@ participant "Unit 1" as unit_1 <> participant "Unit 2" as unit_2 <> unit_1 -> unit_2 : GetData() -unit_2 --> unit_1 : return : Data* +return Data* @enduml diff --git a/plantuml/parser/docs/sequence-diagram.md b/plantuml/parser/docs/sequence-diagram.md new file mode 100644 index 00000000..5bbb30e1 --- /dev/null +++ b/plantuml/parser/docs/sequence-diagram.md @@ -0,0 +1,301 @@ + + +# Sequence Diagram Support Guide + +This guide describes the syntax and semantics currently supported by the PlantUML sequence diagram parser and resolver. The goal is to produce sequence interactions that can be consumed by the logical model, rather than only rendering a diagram. + +**Recommended guidelines:** + +- Declare participants explicitly. +- Use aliases consistently after declaring them. +- Use exactly one arrowhead for each message. +- Split complex lifecycle actions into individual actions. +- Explicitly close every group with `end`. + +## Minimal valid example + +The following example uses only structures that are fully modeled and can be used as a starting point for a new file: + +```text +@startuml OrderFlow + +actor Client +participant "Order Service" as OrderService <> +database Orders + +Client -> OrderService : submit(order) +activate OrderService + +alt order is valid + OrderService -> Orders : save(order) +else order is invalid + OrderService --> Client : rejected +end + +deactivate OrderService +@enduml +``` + +After resolution, this example contains a participant table, message interactions, activation/deactivation actions, and a conditional branch node with two branches. + +## Supported and modeled content + +### Participants + +The following participant declaration forms are supported: + +```text +participant Service +participant "Order Service" +participant "Order Service" as OrderService +participant OrderService as "Order Service" // not recommended +participant "Order Service" as OrderService <> +``` + +The alias form is recommended. Although `participant OrderService as "Order Service"` is supported by the current grammar, the consistent form should be `"Display Name" as Alias` to avoid confusing display names with reference names. + +The display name, alias, participant type, and stereotype are written to the logical model. + +```text +participant "Order Service" as OrderService +Client -> OrderService : correct() +``` + +After declaring an alias, subsequent messages, lifecycle commands, and `ref` blocks should use that alias consistently. Referring to the quoted display name instead may create a separate implicit participant, so using the display name as a message endpoint is not recommended. + +Undeclared message endpoints are automatically created as regular `participant` instances. This is convenient for short diagrams, but explicit declarations are recommended for production diagrams to preserve participant type, stereotype, and stable source locations. + +### Messages and arrow direction + +Messages with exactly one arrowhead are supported. + +```text +A -> B : A call B with a solid line +A --> B : A call B with a dashed line +A <- B : B call A with a solid line // not recommended +A <-- B : B call A with a dashed line // not recommended +``` + +The resolver determines the sender and receiver from the arrow direction. Reverse arrows such as `<-` are not recommended. Line styles and arrow decorations do not change the sender/receiver relationship. The message text follows the colon and may be omitted: + +```text +Service -> Worker : process(item) +Worker --> Service :result +``` + +The difference between `->` and `-->` is only the line style. The resolver parses both as the same kind of logical message (`Interaction`). + +`-->` does not automatically acquire response semantics. If a response only needs to be shown in the PlantUML diagram, **use `return`**: + +```text +Service -> Worker : process(item) +return result +``` + +The parser accepts `return result` and PlantUML can render it as a response. The resolver does not convert it into an interaction node in the logical model. If the response must enter the logical model, use an explicit reverse message such as `Worker --> Service : result`. This is resolved as `Worker -> Service` with the message text `"result"`. + +Self-messages, messages with a missing endpoint, and lost/found markers are supported: + +```text +Service -> Service : retry() +--> Service : incoming request +Service --> : outgoing response +[-> Service : incoming from outside +Service -->] : outgoing to outside +``` + +Messages with missing endpoints and lost/found markers (`[`, `]`, and `?`) are represented with a `null` endpoint in the logical model and do not create user-declared participants. Avoid these forms unless necessary; when modeling an external interaction, explicitly declaring a participant is recommended. + +### Lifecycle + +Standalone commands and message suffixes are supported. Each action with a concrete participant target produces a lifecycle node. Lost/found endpoints have no participant target and therefore do not produce a corresponding lifecycle node. + +```text +create Worker +activate Worker +deactivate Worker + +Worker ++ +Worker -- + +Service -> Worker ++ : activate Worker +Worker -> Repository ** : create Repository +Repository --> Worker -- : response +Service -> Worker !! : destroy Worker +``` + +Message suffixes apply as follows: + +| Suffix | Action | Target | +|---|---|---| +| `++` | activate | Message receiver | +| `--` | deactivate | Message sender | +| `**` | create | Message receiver, before the interaction | +| `!!` | destroy | Message receiver | + +Standalone `create`, `activate`, `deactivate`, and `destroy` commands are recommended. Message suffixes are suitable when the action is strictly bound to a single message. + +The `create` command may also specify a participant type, display name, alias, and stereotype, for example `create database "Event Store" as EventStore <>`. These participant properties are preserved in the participant model. + +Combined suffixes are supported. The resolver creates lifecycle nodes in the source-code order of the suffixes. `**` creates the participant before the message interaction; other actions are created after the interaction. For example: + +```text +Service -> Worker --++ : response // not recommended +``` + +is equivalent to the following logical nodes: + +```text +Interaction(Service -> Worker) +Lifecycle(Service, Deactivate) +Lifecycle(Worker, Activate) +``` + +Here, `--` applies to the message sender, while `++` and `!!` apply to the message receiver. Combined suffixes should still be used only when their semantics and order are clear. + +Within the same logical block or branch, the resolver prohibits using a participant after `destroy` through a message, reference, `activate`, or `deactivate`. A destruction state inside a branch does not propagate outside that branch. To explicitly make the participant usable again, first use `create`, or recreate it with a single `**` suffix: + +```text +create Worker +destroy Worker +create Worker +Service -> Worker : usable again +``` + +### Control structures + +Nested `alt`, `opt`, `loop`, `par`, `break`, and `group` structures are supported. `alt`, `opt`, `loop`, `par`, and `break` produce logical nodes; `group` is only a transparent container. Labels are retained as conditions, loop conditions, parallel-branch labels, or break reasons. `critical` is accepted by the parser but is not part of the current logical model. + +```text +// alt-else +alt cache hit + Client -> Service : return cached value +else cache miss + Client -> Service : fetch value +end + +// opt +opt tracing enabled + Service -> Audit : record() +end + +// loop +loop while pending + Client -> Service : poll() +end + +// par-else +par primary path + Service -> Worker : dispatch() +else audit path + Service -> Audit : record() +end + +// break +break invalid request + Service --> Client : rejected +end +``` + +Rules: + +- The current version accepts `critical` syntax and checks its group closure, but does not create a logical node; its contents do not enter the logical model. +- `else` is allowed only in `alt` and `par`. Using it in `opt`, `loop`, `break`, or `group` produces an error. +- An `else` in `alt` creates an additional conditional branch; an `else` in `par` creates an additional parallel branch. +- Every group must be closed with `end`. The untyped form `end` and a matching form such as `end loop` are both allowed. +- A typed end statement must match the innermost group, so `loop ... end alt` is invalid. +- `group` can improve diagram readability, but it is transparent in the logical model: its interactions are promoted to the outer level. Use `alt`, `opt`, `loop`, `par`, or `break` when downstream tools need to understand business control flow. + +### Reference blocks + +Single-line and multi-line `ref over` blocks are supported. The participant list and reference text are retained: + +```text +ref over Service, Worker : shared behavior + +ref over Service, Worker + See the separate retry sequence. +end ref +``` + +It is recommended that participants in a `ref` block are established by a declaration, message, or `create` first. A `ref` block does not create implicit participants. Referencing a destroyed participant produces an error. + +## Content accepted but not included in the logical model + +The following syntax is accepted or skipped during parsing for compatibility with existing PlantUML source files. The resolver does not create logical nodes for it or retain its visual effects. Do not rely on this syntax to express business semantics that downstream tools must understand. + +In particular, the current logical model does not support `return` or `critical`. The parser accepts them, but the resolver does not create a return interaction node or a critical-section logical node. Use a supported explicit message or control structure when the business meaning must enter the logical model. + +| Category | Accepted example | Current behavior | +|---|---|---| +| Participant order and colors | `participant Service order 1 #LightBlue` | Parsed, but order and color are ignored | +| Lifecycle colors | `activate Service #LightBlue`, `Service -> Worker ++ #gold` | Parsed, but color is ignored | +| Parallel message marker | `&Service -> Worker : parallel call` | Parsed, but the parallel marker is ignored; use a `par` block for modeled parallel control flow | +| Visual grouping | `group Deployment ... end group` | Contents are retained, but no `group` container node is created | +| Critical section | `critical exclusive ... end` | Syntax and group balance are checked, but its contents do not enter the logical model | +| Return command | `return result` | Parsed, but no interaction or return node is created | +| Title, legend, sprite, and transformation | `legend ... end legend`, `sprite ...` | Ignored | +| Preprocessing and display settings | `!pragma`, `!function`, `skinparam`, `autonumber`, `autoactivate`, `footbox` | Ignored | +| Layout and visibility | `box ... end box`, `minwidth`, `rotate`, `hide`, `show`, `== section ==`, `...`, `delay` | Ignored | + +## Resolver errors + +### Messages with no unique direction + +A message must have exactly one arrowhead. Bidirectional or directionless arrows produce an error during resolution: + +```text +A <--> B : invalid +A -- B : invalid +``` + +In addition to invalid message directions, the resolver rejects sequence diagrams in the following cases: + +| Case | Example or description | Recommended fix | +|---|---|---| +| Unclosed group | `alt ...` without `end` | Add an end statement for every `alt`, `opt`, `loop`, `par`, `break`, `group`, or `critical` | +| Mismatched group end | `loop ... end alt` | Make the end statement match the innermost group type | +| `else` in an invalid group | `opt ... else ... end` | Use `else` only in `alt` or `par` | +| Use of a destroyed participant | Sending a message or executing `ref`, `activate`, or `deactivate` after `destroy Worker` | Use `create Worker` first or reorder the interaction | + +These errors usually include the source file and line number. The repository also provides corresponding error cases, including: +- [invalid message direction](../integration_test/sequence_diagram/invalid_message_direction/invalid_message_direction.puml) +- [an unclosed group](../integration_test/sequence_diagram/invalid_unterminated_group/invalid_unterminated_group.puml) +- [an invalid `else`](../integration_test/sequence_diagram/invalid_else_in_opt/invalid_else_in_opt.puml) +- [a mismatched group end](../integration_test/sequence_diagram/invalid_mismatched_group_end/invalid_mismatched_group_end.puml) +- [use of a participant after destruction](../integration_test/sequence_diagram/invalid_destroyed_participant_use/invalid_destroyed_participant_use.puml) + +## Authoring checklist + +Use the following checklist when submitting a sequence diagram: + +1. The file starts with `@startuml` and ends with `@enduml`. +2. Important participants are declared explicitly, with stable aliases for names containing spaces or complex display names. +3. Once an alias is declared, references use that alias consistently to avoid creating a separate implicit participant. +4. Every message has exactly one arrowhead. Use `return` only for responses shown in the diagram; use an explicit reverse message when the response must enter the logical model. +5. Combined message suffixes have clear semantics and order. `--` applies to the sender, `++` and `!!` apply to the receiver, and `**` creates the receiver before the interaction. +6. Every control structure is closed, and `else` appears only in `alt` or `par`. +7. A participant is not used after `destroy` unless it is created again first. +8. Interactions that downstream tools must understand use only structures described as supported and modeled; visual-only directives do not carry business semantics. + +## Related test cases + +The repository provides runnable end-to-end examples: + +- [Complete sequence diagram](../integration_test/sequence_diagram/comprehensive_sequence_test.puml) +- [Lifecycle](../integration_test/sequence_diagram/sequence_lifecycle_nodes/sequence_lifecycle_nodes.puml) +- [Conditional branch](../integration_test/sequence_diagram/sequence_branch_node/sequence_branch_node.puml) +- [Parallel branch](../integration_test/sequence_diagram/sequence_parallel_node/sequence_parallel_node.puml) +- [Loop](../integration_test/sequence_diagram/sequence_loop_node/sequence_loop_node.puml) +- [Reference](../integration_test/sequence_diagram/sequence_reference_node/sequence_reference_node.puml) diff --git a/plantuml/parser/integration_test/sequence_diagram/combined_lifecycle_suffix/combined_lifecycle_suffix.puml b/plantuml/parser/integration_test/sequence_diagram/combined_lifecycle_suffix/combined_lifecycle_suffix.puml new file mode 100644 index 00000000..33657e0d --- /dev/null +++ b/plantuml/parser/integration_test/sequence_diagram/combined_lifecycle_suffix/combined_lifecycle_suffix.puml @@ -0,0 +1,19 @@ +' ******************************************************************************* +' Copyright (c) 2026 Contributors to the Eclipse Foundation +' +' See the NOTICE file(s) distributed with this work for additional +' information regarding copyright ownership. +' +' This program and the accompanying materials are made available under the +' terms of the Apache License Version 2.0 which is available at +' https://www.apache.org/licenses/LICENSE-2.0 +' +' SPDX-License-Identifier: Apache-2.0 +' ******************************************************************************* + +@startuml combined_lifecycle_suffix + +alice -> bob --++ : hello +alice -> bob -- : step1 + +@enduml diff --git a/plantuml/parser/integration_test/sequence_diagram/combined_lifecycle_suffix/output.json b/plantuml/parser/integration_test/sequence_diagram/combined_lifecycle_suffix/output.json new file mode 100644 index 00000000..abaa33db --- /dev/null +++ b/plantuml/parser/integration_test/sequence_diagram/combined_lifecycle_suffix/output.json @@ -0,0 +1,83 @@ +{ + "combined_lifecycle_suffix.puml": { + "name": "combined_lifecycle_suffix", + "participants": [ + { + "display_name": "alice", + "alias": null, + "participant_type": "Participant", + "source_location": { + "file": "", + "line": 16 + }, + "stereotype": null + }, + { + "display_name": "bob", + "alias": null, + "participant_type": "Participant", + "source_location": { + "file": "", + "line": 16 + }, + "stereotype": null + } + ], + "root": { + "items": [ + { + "Interaction": { + "sender": "alice", + "receiver": "bob", + "message": "hello", + "source_location": { + "file": "", + "line": 16 + } + } + }, + { + "Lifecycle": { + "participant": "alice", + "action": "Deactivate", + "source_location": { + "file": "", + "line": 16 + } + } + }, + { + "Lifecycle": { + "participant": "bob", + "action": "Activate", + "source_location": { + "file": "", + "line": 16 + } + } + }, + { + "Interaction": { + "sender": "alice", + "receiver": "bob", + "message": "step1", + "source_location": { + "file": "", + "line": 17 + } + } + }, + { + "Lifecycle": { + "participant": "alice", + "action": "Deactivate", + "source_location": { + "file": "", + "line": 17 + } + } + } + ] + } + } +} diff --git a/plantuml/parser/integration_test/sequence_diagram/comprehensive_sequence_test.puml b/plantuml/parser/integration_test/sequence_diagram/comprehensive_sequence_test.puml index 23243e06..db4dc8aa 100644 --- a/plantuml/parser/integration_test/sequence_diagram/comprehensive_sequence_test.puml +++ b/plantuml/parser/integration_test/sequence_diagram/comprehensive_sequence_test.puml @@ -174,8 +174,11 @@ end loop Until success alt condition met break + Actor1 -> Actor2 : Abort + end + else + Actor1 -> Actor2 : Continue processing end - Actor1 -> Actor2 : Continue processing end ' ======================================== @@ -357,7 +360,7 @@ group Extended Patterns: Activation Markers end group group Extended Patterns: Ref Blocks - ref over Actor1, Actor2 + ref over Actor1 See other diagram for details [[https://example.com/reference Reference document]] end ref @@ -369,7 +372,7 @@ end group group Extended Patterns: Dividers with URLs == Setup Phase == - Actor1 -> Actor2 : Initialize + Actor1 -> Actor2 ** : Initialize == Processing https://example.com/docs == Actor2 -> Builder : Process diff --git a/plantuml/parser/integration_test/sequence_diagram/implicit_participant_from_message_endpoint/output.json b/plantuml/parser/integration_test/sequence_diagram/implicit_participant_from_message_endpoint/output.json index 44c58530..ebec7051 100644 --- a/plantuml/parser/integration_test/sequence_diagram/implicit_participant_from_message_endpoint/output.json +++ b/plantuml/parser/integration_test/sequence_diagram/implicit_participant_from_message_endpoint/output.json @@ -23,21 +23,20 @@ "stereotype": null } ], - "root_interactions": [ - { - "event": { + "root": { + "items": [ + { "Interaction": { - "caller": "Client", - "callee": "Service", - "method": "request()" + "sender": "Client", + "receiver": "Service", + "message": "request()", + "source_location": { + "file": "", + "line": 18 + } } - }, - "source_location": { - "file": "", - "line": 18 - }, - "branches_node": [] - } - ] + } + ] + } } -} +} \ No newline at end of file diff --git a/plantuml/parser/integration_test/sequence_diagram/invalid_destroyed_participant_use/invalid_destroyed_participant_use.puml b/plantuml/parser/integration_test/sequence_diagram/invalid_destroyed_participant_use/invalid_destroyed_participant_use.puml new file mode 100644 index 00000000..7fce2972 --- /dev/null +++ b/plantuml/parser/integration_test/sequence_diagram/invalid_destroyed_participant_use/invalid_destroyed_participant_use.puml @@ -0,0 +1,23 @@ +' ******************************************************************************* +' Copyright (c) 2026 Contributors to the Eclipse Foundation +' +' See the NOTICE file(s) distributed with this work for additional +' information regarding copyright ownership. +' +' This program and the accompanying materials are made available under the +' terms of the Apache License Version 2.0 which is available at +' https://www.apache.org/licenses/LICENSE-2.0 +' +' SPDX-License-Identifier: Apache-2.0 +' ******************************************************************************* + +@startuml invalid_destroyed_participant_use + +participant A +participant B + +A -> B : first() +destroy B +A -> B : afterDestroy() + +@enduml diff --git a/plantuml/parser/integration_test/sequence_diagram/invalid_destroyed_participant_use/output.yaml b/plantuml/parser/integration_test/sequence_diagram/invalid_destroyed_participant_use/output.yaml new file mode 100644 index 00000000..3883c588 --- /dev/null +++ b/plantuml/parser/integration_test/sequence_diagram/invalid_destroyed_participant_use/output.yaml @@ -0,0 +1,18 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +invalid_destroyed_participant_use.puml: + error: + type: "DestroyedParticipantUse" + fields: + participant: "B" + line: "21" diff --git a/plantuml/parser/integration_test/sequence_diagram/invalid_else_in_opt/invalid_else_in_opt.puml b/plantuml/parser/integration_test/sequence_diagram/invalid_else_in_opt/invalid_else_in_opt.puml new file mode 100644 index 00000000..90e0ba06 --- /dev/null +++ b/plantuml/parser/integration_test/sequence_diagram/invalid_else_in_opt/invalid_else_in_opt.puml @@ -0,0 +1,25 @@ +' ******************************************************************************* +' Copyright (c) 2026 Contributors to the Eclipse Foundation +' +' See the NOTICE file(s) distributed with this work for additional +' information regarding copyright ownership. +' +' This program and the accompanying materials are made available under the +' terms of the Apache License Version 2.0 which is available at +' https://www.apache.org/licenses/LICENSE-2.0 +' +' SPDX-License-Identifier: Apache-2.0 +' ******************************************************************************* + +@startuml invalid_else_in_opt + +participant A +participant B + +opt optional path + A -> B : primary +else fallback path + A -> B : fallback +end + +@enduml diff --git a/plantuml/parser/integration_test/sequence_diagram/invalid_else_in_opt/output.yaml b/plantuml/parser/integration_test/sequence_diagram/invalid_else_in_opt/output.yaml new file mode 100644 index 00000000..306170c9 --- /dev/null +++ b/plantuml/parser/integration_test/sequence_diagram/invalid_else_in_opt/output.yaml @@ -0,0 +1,18 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +invalid_else_in_opt.puml: + error: + type: ElseNotAllowedInGroup + fields: + kind: Opt + line: "21" diff --git a/plantuml/parser/integration_test/sequence_diagram/invalid_message_direction/invalid_message_direction.puml b/plantuml/parser/integration_test/sequence_diagram/invalid_message_direction/invalid_message_direction.puml new file mode 100644 index 00000000..921a8315 --- /dev/null +++ b/plantuml/parser/integration_test/sequence_diagram/invalid_message_direction/invalid_message_direction.puml @@ -0,0 +1,18 @@ +' ******************************************************************************* +' Copyright (c) 2026 Contributors to the Eclipse Foundation +' +' See the NOTICE file(s) distributed with this work for additional +' information regarding copyright ownership. +' +' This program and the accompanying materials are made available under the +' terms of the Apache License Version 2.0 which is available at +' https://www.apache.org/licenses/LICENSE-2.0 +' +' SPDX-License-Identifier: Apache-2.0 +' ******************************************************************************* + +@startuml invalid_message_direction + +alice <--> bob: ambiguous + +@enduml diff --git a/plantuml/parser/integration_test/sequence_diagram/invalid_message_direction/output.yaml b/plantuml/parser/integration_test/sequence_diagram/invalid_message_direction/output.yaml new file mode 100644 index 00000000..20177edb --- /dev/null +++ b/plantuml/parser/integration_test/sequence_diagram/invalid_message_direction/output.yaml @@ -0,0 +1,18 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +invalid_message_direction.puml: + error: + type: InvalidMessageDirection + fields: + arrow: <--> + line: "16" diff --git a/plantuml/parser/integration_test/sequence_diagram/invalid_mismatched_group_end/invalid_mismatched_group_end.puml b/plantuml/parser/integration_test/sequence_diagram/invalid_mismatched_group_end/invalid_mismatched_group_end.puml new file mode 100644 index 00000000..fa87cc06 --- /dev/null +++ b/plantuml/parser/integration_test/sequence_diagram/invalid_mismatched_group_end/invalid_mismatched_group_end.puml @@ -0,0 +1,20 @@ +' ******************************************************************************* +' Copyright (c) 2026 Contributors to the Eclipse Foundation +' +' See the NOTICE file(s) distributed with this work for additional +' information regarding copyright ownership. +' +' This program and the accompanying materials are made available under the +' terms of the Apache License Version 2.0 which is available at +' https://www.apache.org/licenses/LICENSE-2.0 +' +' SPDX-License-Identifier: Apache-2.0 +' ******************************************************************************* + +@startuml invalid_mismatched_group_end + +alt primary path + A -> B : request +end loop + +@enduml diff --git a/plantuml/parser/integration_test/sequence_diagram/invalid_mismatched_group_end/output.yaml b/plantuml/parser/integration_test/sequence_diagram/invalid_mismatched_group_end/output.yaml new file mode 100644 index 00000000..c1c9e613 --- /dev/null +++ b/plantuml/parser/integration_test/sequence_diagram/invalid_mismatched_group_end/output.yaml @@ -0,0 +1,19 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +invalid_mismatched_group_end.puml: + error: + type: MismatchedGroupEnd + fields: + expected: Alt + found: Loop + line: "18" diff --git a/plantuml/parser/integration_test/sequence_diagram/invalid_unterminated_group/invalid_unterminated_group.puml b/plantuml/parser/integration_test/sequence_diagram/invalid_unterminated_group/invalid_unterminated_group.puml new file mode 100644 index 00000000..c93b49fe --- /dev/null +++ b/plantuml/parser/integration_test/sequence_diagram/invalid_unterminated_group/invalid_unterminated_group.puml @@ -0,0 +1,19 @@ +' ******************************************************************************* +' Copyright (c) 2026 Contributors to the Eclipse Foundation +' +' See the NOTICE file(s) distributed with this work for additional +' information regarding copyright ownership. +' +' This program and the accompanying materials are made available under the +' terms of the Apache License Version 2.0 which is available at +' https://www.apache.org/licenses/LICENSE-2.0 +' +' SPDX-License-Identifier: Apache-2.0 +' ******************************************************************************* + +@startuml unterminated_group + +loop retry + Alice -> Service : request() + +@enduml diff --git a/plantuml/parser/integration_test/sequence_diagram/invalid_unterminated_group/output.yaml b/plantuml/parser/integration_test/sequence_diagram/invalid_unterminated_group/output.yaml new file mode 100644 index 00000000..eeeba36a --- /dev/null +++ b/plantuml/parser/integration_test/sequence_diagram/invalid_unterminated_group/output.yaml @@ -0,0 +1,17 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +invalid_unterminated_group.puml: + error: + type: UnterminatedGroup + fields: + line: "16" diff --git a/plantuml/parser/integration_test/sequence_diagram/lost_found_endpoint_resolution/lost_found_endpoint_resolution.puml b/plantuml/parser/integration_test/sequence_diagram/lost_found_endpoint_resolution/lost_found_endpoint_resolution.puml index 30ae43d8..0d5a85d1 100644 --- a/plantuml/parser/integration_test/sequence_diagram/lost_found_endpoint_resolution/lost_found_endpoint_resolution.puml +++ b/plantuml/parser/integration_test/sequence_diagram/lost_found_endpoint_resolution/lost_found_endpoint_resolution.puml @@ -13,7 +13,7 @@ @startuml lost_found_endpoint_resolution -[ -> Service : incoming -Service --> ] : outgoing +[-> Service : incoming +Service -->] : outgoing @enduml diff --git a/plantuml/parser/integration_test/sequence_diagram/lost_found_endpoint_resolution/output.json b/plantuml/parser/integration_test/sequence_diagram/lost_found_endpoint_resolution/output.json index 8b660c88..6f8dcc0b 100644 --- a/plantuml/parser/integration_test/sequence_diagram/lost_found_endpoint_resolution/output.json +++ b/plantuml/parser/integration_test/sequence_diagram/lost_found_endpoint_resolution/output.json @@ -13,36 +13,31 @@ "stereotype": null } ], - "root_interactions": [ - { - "event": { + "root": { + "items": [ + { "Interaction": { - "caller": "ExternalEndpoint", - "callee": "Service", - "method": "incoming" + "sender": null, + "receiver": "Service", + "message": "incoming", + "source_location": { + "file": "", + "line": 16 + } } }, - "source_location": { - "file": "", - "line": 16 - }, - "branches_node": [ - { - "event": { - "Return": { - "caller": "ExternalEndpoint", - "callee": "Service", - "return_content": "outgoing" - } - }, + { + "Interaction": { + "sender": "Service", + "receiver": null, + "message": "outgoing", "source_location": { "file": "", "line": 17 - }, - "branches_node": [] + } } - ] - } - ] + } + ] + } } } diff --git a/plantuml/parser/integration_test/sequence_diagram/participant_identifier_examples/output.json b/plantuml/parser/integration_test/sequence_diagram/participant_identifier_examples/output.json index 64a148f5..75c0fc70 100644 --- a/plantuml/parser/integration_test/sequence_diagram/participant_identifier_examples/output.json +++ b/plantuml/parser/integration_test/sequence_diagram/participant_identifier_examples/output.json @@ -33,36 +33,31 @@ "stereotype": null } ], - "root_interactions": [ - { - "event": { + "root": { + "items": [ + { "Interaction": { - "caller": "Caller", - "callee": "Service", - "method": "callByAlias()" + "sender": "Caller", + "receiver": "Service", + "message": "callByAlias()", + "source_location": { + "file": "", + "line": 19 + } } }, - "source_location": { - "file": "", - "line": 19 - }, - "branches_node": [ - { - "event": { - "Interaction": { - "caller": "Service", - "callee": "Display Service", - "method": "callByDisplayName()" - } - }, + { + "Interaction": { + "sender": "Service", + "receiver": "Display Service", + "message": "callByDisplayName()", "source_location": { "file": "", "line": 22 - }, - "branches_node": [] + } } - ] - } - ] + } + ] + } } -} +} \ No newline at end of file diff --git a/plantuml/parser/integration_test/sequence_diagram/recreate_destroyed_participant/output.json b/plantuml/parser/integration_test/sequence_diagram/recreate_destroyed_participant/output.json new file mode 100644 index 00000000..ac4bf207 --- /dev/null +++ b/plantuml/parser/integration_test/sequence_diagram/recreate_destroyed_participant/output.json @@ -0,0 +1,73 @@ +{ + "recreate_destroyed_participant.puml": { + "name": "recreate_destroyed_participant", + "participants": [ + { + "display_name": "A", + "alias": null, + "participant_type": "Participant", + "source_location": { + "file": "", + "line": 16 + }, + "stereotype": null + }, + { + "display_name": "B", + "alias": null, + "participant_type": "Participant", + "source_location": { + "file": "", + "line": 16 + }, + "stereotype": null + } + ], + "root": { + "items": [ + { + "Interaction": { + "sender": "A", + "receiver": "B", + "message": "first()", + "source_location": { + "file": "", + "line": 16 + } + } + }, + { + "Lifecycle": { + "participant": "B", + "action": "Destroy", + "source_location": { + "file": "", + "line": 17 + } + } + }, + { + "Lifecycle": { + "participant": "B", + "action": "Create", + "source_location": { + "file": "", + "line": 18 + } + } + }, + { + "Interaction": { + "sender": "A", + "receiver": "B", + "message": "afterCreate()", + "source_location": { + "file": "", + "line": 19 + } + } + } + ] + } + } +} diff --git a/plantuml/parser/integration_test/sequence_diagram/recreate_destroyed_participant/recreate_destroyed_participant.puml b/plantuml/parser/integration_test/sequence_diagram/recreate_destroyed_participant/recreate_destroyed_participant.puml new file mode 100644 index 00000000..7e91aa27 --- /dev/null +++ b/plantuml/parser/integration_test/sequence_diagram/recreate_destroyed_participant/recreate_destroyed_participant.puml @@ -0,0 +1,21 @@ +' ******************************************************************************* +' Copyright (c) 2026 Contributors to the Eclipse Foundation +' +' See the NOTICE file(s) distributed with this work for additional +' information regarding copyright ownership. +' +' This program and the accompanying materials are made available under the +' terms of the Apache License Version 2.0 which is available at +' https://www.apache.org/licenses/LICENSE-2.0 +' +' SPDX-License-Identifier: Apache-2.0 +' ******************************************************************************* + +@startuml recreate_destroyed_participant + +A -> B : first() +destroy B +create B +A -> B : afterCreate() + +@enduml diff --git a/plantuml/parser/integration_test/sequence_diagram/sequence_arrow_direction/output.json b/plantuml/parser/integration_test/sequence_diagram/sequence_arrow_direction/output.json new file mode 100644 index 00000000..507a60d1 --- /dev/null +++ b/plantuml/parser/integration_test/sequence_diagram/sequence_arrow_direction/output.json @@ -0,0 +1,57 @@ +{ + "sequence_arrow_direction.puml": { + "name": "sequence_arrow_direction", + "participants": [ + { + "display_name": "A", + "alias": null, + "participant_type": "Participant", + "source_location": { "file": "", "line": 16 }, + "stereotype": null + }, + { + "display_name": "B", + "alias": null, + "participant_type": "Participant", + "source_location": { "file": "", "line": 17 }, + "stereotype": null + } + ], + "root": { + "items": [ + { + "Interaction": { + "sender": "A", + "receiver": "B", + "message": "solid right", + "source_location": { "file": "", "line": 19 } + } + }, + { + "Interaction": { + "sender": "B", + "receiver": "A", + "message": "solid left", + "source_location": { "file": "", "line": 20 } + } + }, + { + "Interaction": { + "sender": "A", + "receiver": "B", + "message": "dashed right", + "source_location": { "file": "", "line": 21 } + } + }, + { + "Interaction": { + "sender": "B", + "receiver": "A", + "message": "dashed left", + "source_location": { "file": "", "line": 22 } + } + } + ] + } + } +} diff --git a/plantuml/parser/integration_test/sequence_diagram/sequence_arrow_direction/sequence_arrow_direction.puml b/plantuml/parser/integration_test/sequence_diagram/sequence_arrow_direction/sequence_arrow_direction.puml new file mode 100644 index 00000000..c7127f85 --- /dev/null +++ b/plantuml/parser/integration_test/sequence_diagram/sequence_arrow_direction/sequence_arrow_direction.puml @@ -0,0 +1,24 @@ +' ******************************************************************************* +' Copyright (c) 2026 Contributors to the Eclipse Foundation +' +' See the NOTICE file(s) distributed with this work for additional +' information regarding copyright ownership. +' +' This program and the accompanying materials are made available under the +' terms of the Apache License Version 2.0 which is available at +' https://www.apache.org/licenses/LICENSE-2.0 +' +' SPDX-License-Identifier: Apache-2.0 +' ******************************************************************************* + +@startuml sequence_arrow_direction + +participant A +participant B + +A -> B : solid right +A <- B : solid left +A --> B : dashed right +A <-- B : dashed left + +@enduml diff --git a/plantuml/parser/integration_test/sequence_diagram/sequence_branch_node/output.json b/plantuml/parser/integration_test/sequence_diagram/sequence_branch_node/output.json new file mode 100644 index 00000000..11afe05e --- /dev/null +++ b/plantuml/parser/integration_test/sequence_diagram/sequence_branch_node/output.json @@ -0,0 +1,81 @@ +{ + "sequence_branch_node.puml": { + "name": "sequence_branch_node", + "participants": [ + { + "display_name": "Client", + "alias": null, + "participant_type": "Participant", + "source_location": { + "file": "", + "line": 16 + }, + "stereotype": null + }, + { + "display_name": "Service", + "alias": null, + "participant_type": "Participant", + "source_location": { + "file": "", + "line": 17 + }, + "stereotype": null + } + ], + "root": { + "items": [ + { + "Branch": { + "cases": [ + { + "condition": "ready", + "block": { + "items": [ + { + "Interaction": { + "sender": "Client", + "receiver": "Service", + "message": "request()", + "source_location": { + "file": "", + "line": 20 + } + } + } + ] + }, + "source_location": { + "file": "", + "line": 19 + } + }, + { + "condition": "fallback", + "block": { + "items": [ + { + "Interaction": { + "sender": "Client", + "receiver": "Service", + "message": "fallback()", + "source_location": { + "file": "", + "line": 22 + } + } + } + ] + }, + "source_location": { + "file": "", + "line": 21 + } + } + ] + } + } + ] + } + } +} diff --git a/plantuml/parser/integration_test/sequence_diagram/sequence_branch_node/sequence_branch_node.puml b/plantuml/parser/integration_test/sequence_diagram/sequence_branch_node/sequence_branch_node.puml new file mode 100644 index 00000000..b309faee --- /dev/null +++ b/plantuml/parser/integration_test/sequence_diagram/sequence_branch_node/sequence_branch_node.puml @@ -0,0 +1,25 @@ +' ******************************************************************************* +' Copyright (c) 2026 Contributors to the Eclipse Foundation +' +' See the NOTICE file(s) distributed with this work for additional +' information regarding copyright ownership. +' +' This program and the accompanying materials are made available under the +' terms of the Apache License Version 2.0 which is available at +' https://www.apache.org/licenses/LICENSE-2.0 +' +' SPDX-License-Identifier: Apache-2.0 +' ******************************************************************************* + +@startuml sequence_branch_node + +participant Client +participant Service + +alt ready + Client -> Service : request() +else fallback + Client -> Service : fallback() +end + +@enduml diff --git a/plantuml/parser/integration_test/sequence_diagram/sequence_early_exit_node/output.json b/plantuml/parser/integration_test/sequence_diagram/sequence_early_exit_node/output.json new file mode 100644 index 00000000..6362c600 --- /dev/null +++ b/plantuml/parser/integration_test/sequence_diagram/sequence_early_exit_node/output.json @@ -0,0 +1,55 @@ +{ + "sequence_early_exit_node.puml": { + "name": "sequence_early_exit_node", + "participants": [ + { + "display_name": "Client", + "alias": null, + "participant_type": "Participant", + "source_location": { + "file": "", + "line": 16 + }, + "stereotype": null + }, + { + "display_name": "Service", + "alias": null, + "participant_type": "Participant", + "source_location": { + "file": "", + "line": 17 + }, + "stereotype": null + } + ], + "root": { + "items": [ + { + "EarlyExit": { + "reason": "invalid request", + "block": { + "items": [ + { + "Interaction": { + "sender": "Service", + "receiver": "Client", + "message": "reject()", + "source_location": { + "file": "", + "line": 20 + } + } + } + ] + }, + "source_location": { + "file": "", + "line": 19 + } + } + } + ] + } + } +} diff --git a/plantuml/parser/integration_test/sequence_diagram/sequence_early_exit_node/sequence_early_exit_node.puml b/plantuml/parser/integration_test/sequence_diagram/sequence_early_exit_node/sequence_early_exit_node.puml new file mode 100644 index 00000000..ffc89cfa --- /dev/null +++ b/plantuml/parser/integration_test/sequence_diagram/sequence_early_exit_node/sequence_early_exit_node.puml @@ -0,0 +1,23 @@ +' ******************************************************************************* +' Copyright (c) 2026 Contributors to the Eclipse Foundation +' +' See the NOTICE file(s) distributed with this work for additional +' information regarding copyright ownership. +' +' This program and the accompanying materials are made available under the +' terms of the Apache License Version 2.0 which is available at +' https://www.apache.org/licenses/LICENSE-2.0 +' +' SPDX-License-Identifier: Apache-2.0 +' ******************************************************************************* + +@startuml sequence_early_exit_node + +participant Client +participant Service + +break invalid request + Service -> Client : reject() +end + +@enduml diff --git a/plantuml/parser/integration_test/sequence_diagram/sequence_group_container/output.json b/plantuml/parser/integration_test/sequence_diagram/sequence_group_container/output.json new file mode 100644 index 00000000..7e4df32c --- /dev/null +++ b/plantuml/parser/integration_test/sequence_diagram/sequence_group_container/output.json @@ -0,0 +1,114 @@ +{ + "sequence_group_container.puml": { + "name": "sequence_group_container", + "participants": [ + { + "display_name": "Client", + "alias": null, + "participant_type": "Participant", + "source_location": { + "file": "", + "line": 16 + }, + "stereotype": null + }, + { + "display_name": "Service", + "alias": null, + "participant_type": "Participant", + "source_location": { + "file": "", + "line": 17 + }, + "stereotype": null + } + ], + "root": { + "items": [ + { + "Interaction": { + "sender": "Client", + "receiver": "Service", + "message": "request()", + "source_location": { + "file": "", + "line": 20 + } + } + }, + { + "Interaction": { + "sender": "Service", + "receiver": "Client", + "message": "response()", + "source_location": { + "file": "", + "line": 21 + } + } + }, + { + "Branch": { + "cases": [ + { + "condition": "accepted", + "block": { + "items": [ + { + "Interaction": { + "sender": "Client", + "receiver": "Service", + "message": "confirm()", + "source_location": { + "file": "", + "line": 23 + } + } + } + ] + }, + "source_location": { + "file": "", + "line": 22 + } + }, + { + "condition": "rejected", + "block": { + "items": [ + { + "Interaction": { + "sender": "Service", + "receiver": "Client", + "message": "reject()", + "source_location": { + "file": "", + "line": 25 + } + } + } + ] + }, + "source_location": { + "file": "", + "line": 24 + } + } + ] + } + }, + { + "Interaction": { + "sender": "Client", + "receiver": "Service", + "message": "close()", + "source_location": { + "file": "", + "line": 27 + } + } + } + ] + } + } +} diff --git a/plantuml/parser/integration_test/sequence_diagram/sequence_group_container/sequence_group_container.puml b/plantuml/parser/integration_test/sequence_diagram/sequence_group_container/sequence_group_container.puml new file mode 100644 index 00000000..fffee870 --- /dev/null +++ b/plantuml/parser/integration_test/sequence_diagram/sequence_group_container/sequence_group_container.puml @@ -0,0 +1,30 @@ +' ******************************************************************************* +' Copyright (c) 2026 Contributors to the Eclipse Foundation +' +' See the NOTICE file(s) distributed with this work for additional +' information regarding copyright ownership. +' +' This program and the accompanying materials are made available under the +' terms of the Apache License Version 2.0 which is available at +' https://www.apache.org/licenses/LICENSE-2.0 +' +' SPDX-License-Identifier: Apache-2.0 +' ******************************************************************************* + +@startuml sequence_group_container + +participant Client +participant Service + +group Visual grouping + Client -> Service : request() + Service -> Client : response() + alt accepted + Client -> Service : confirm() + else rejected + Service -> Client : reject() + end + Client -> Service : close() +end + +@enduml diff --git a/plantuml/parser/integration_test/sequence_diagram/sequence_interaction_node/output.json b/plantuml/parser/integration_test/sequence_diagram/sequence_interaction_node/output.json new file mode 100644 index 00000000..5bf69da6 --- /dev/null +++ b/plantuml/parser/integration_test/sequence_diagram/sequence_interaction_node/output.json @@ -0,0 +1,42 @@ +{ + "sequence_interaction_node.puml": { + "name": "sequence_interaction_node", + "participants": [ + { + "display_name": "Client", + "alias": null, + "participant_type": "Participant", + "source_location": { + "file": "", + "line": 16 + }, + "stereotype": null + }, + { + "display_name": "Service", + "alias": null, + "participant_type": "Participant", + "source_location": { + "file": "", + "line": 17 + }, + "stereotype": null + } + ], + "root": { + "items": [ + { + "Interaction": { + "sender": "Client", + "receiver": "Service", + "message": "request()", + "source_location": { + "file": "", + "line": 19 + } + } + } + ] + } + } +} diff --git a/plantuml/parser/integration_test/sequence_diagram/sequence_interaction_node/sequence_interaction_node.puml b/plantuml/parser/integration_test/sequence_diagram/sequence_interaction_node/sequence_interaction_node.puml new file mode 100644 index 00000000..b8c80193 --- /dev/null +++ b/plantuml/parser/integration_test/sequence_diagram/sequence_interaction_node/sequence_interaction_node.puml @@ -0,0 +1,21 @@ +' ******************************************************************************* +' Copyright (c) 2026 Contributors to the Eclipse Foundation +' +' See the NOTICE file(s) distributed with this work for additional +' information regarding copyright ownership. +' +' This program and the accompanying materials are made available under the +' terms of the Apache License Version 2.0 which is available at +' https://www.apache.org/licenses/LICENSE-2.0 +' +' SPDX-License-Identifier: Apache-2.0 +' ******************************************************************************* + +@startuml sequence_interaction_node + +participant Client +participant Service + +Client -> Service : request() + +@enduml diff --git a/plantuml/parser/integration_test/sequence_diagram/sequence_lifecycle_nodes/output.json b/plantuml/parser/integration_test/sequence_diagram/sequence_lifecycle_nodes/output.json new file mode 100644 index 00000000..b9ab57d2 --- /dev/null +++ b/plantuml/parser/integration_test/sequence_diagram/sequence_lifecycle_nodes/output.json @@ -0,0 +1,136 @@ +{ + "sequence_lifecycle_nodes.puml": { + "name": "sequence_lifecycle_nodes", + "participants": [ + { + "display_name": "Client", + "alias": null, + "participant_type": "Participant", + "source_location": { + "file": "", + "line": 16 + }, + "stereotype": null + }, + { + "display_name": "Service", + "alias": null, + "participant_type": "Participant", + "source_location": { + "file": "", + "line": 17 + }, + "stereotype": null + }, + { + "display_name": "Worker", + "alias": null, + "participant_type": "Participant", + "source_location": { + "file": "", + "line": 20 + }, + "stereotype": null + } + ], + "root": { + "items": [ + { + "Interaction": { + "sender": "Client", + "receiver": "Service", + "message": "start()", + "source_location": { + "file": "", + "line": 19 + } + } + }, + { + "Lifecycle": { + "participant": "Service", + "action": "Activate", + "source_location": { + "file": "", + "line": 19 + } + } + }, + { + "Lifecycle": { + "participant": "Worker", + "action": "Create", + "source_location": { + "file": "", + "line": 20 + } + } + }, + { + "Interaction": { + "sender": "Service", + "receiver": "Worker", + "message": "create()", + "source_location": { + "file": "", + "line": 20 + } + } + }, + { + "Interaction": { + "sender": "Worker", + "receiver": "Service", + "message": "done()", + "source_location": { + "file": "", + "line": 21 + } + } + }, + { + "Interaction": { + "sender": "Service", + "receiver": "Client", + "message": "stop()", + "source_location": { + "file": "", + "line": 22 + } + } + }, + { + "Lifecycle": { + "participant": "Service", + "action": "Deactivate", + "source_location": { + "file": "", + "line": 22 + } + } + }, + { + "Interaction": { + "sender": "Client", + "receiver": "Worker", + "message": "destroy()", + "source_location": { + "file": "", + "line": 23 + } + } + }, + { + "Lifecycle": { + "participant": "Worker", + "action": "Destroy", + "source_location": { + "file": "", + "line": 23 + } + } + } + ] + } + } +} diff --git a/plantuml/parser/integration_test/sequence_diagram/sequence_lifecycle_nodes/sequence_lifecycle_nodes.puml b/plantuml/parser/integration_test/sequence_diagram/sequence_lifecycle_nodes/sequence_lifecycle_nodes.puml new file mode 100644 index 00000000..bdb8ecdc --- /dev/null +++ b/plantuml/parser/integration_test/sequence_diagram/sequence_lifecycle_nodes/sequence_lifecycle_nodes.puml @@ -0,0 +1,25 @@ +' ******************************************************************************* +' Copyright (c) 2026 Contributors to the Eclipse Foundation +' +' See the NOTICE file(s) distributed with this work for additional +' information regarding copyright ownership. +' +' This program and the accompanying materials are made available under the +' terms of the Apache License Version 2.0 which is available at +' https://www.apache.org/licenses/LICENSE-2.0 +' +' SPDX-License-Identifier: Apache-2.0 +' ******************************************************************************* + +@startuml sequence_lifecycle_nodes + +participant Client +participant Service + +Client -> Service ++ : start() +Service -> Worker ** : create() +Worker -> Service : done() +Service -> Client -- : stop() +Client -> Worker !! : destroy() + +@enduml diff --git a/plantuml/parser/integration_test/sequence_diagram/sequence_loop_node/output.json b/plantuml/parser/integration_test/sequence_diagram/sequence_loop_node/output.json new file mode 100644 index 00000000..bc992062 --- /dev/null +++ b/plantuml/parser/integration_test/sequence_diagram/sequence_loop_node/output.json @@ -0,0 +1,55 @@ +{ + "sequence_loop_node.puml": { + "name": "sequence_loop_node", + "participants": [ + { + "display_name": "Client", + "alias": null, + "participant_type": "Participant", + "source_location": { + "file": "", + "line": 16 + }, + "stereotype": null + }, + { + "display_name": "Service", + "alias": null, + "participant_type": "Participant", + "source_location": { + "file": "", + "line": 17 + }, + "stereotype": null + } + ], + "root": { + "items": [ + { + "Loop": { + "condition": "retry while pending", + "block": { + "items": [ + { + "Interaction": { + "sender": "Client", + "receiver": "Service", + "message": "poll()", + "source_location": { + "file": "", + "line": 20 + } + } + } + ] + }, + "source_location": { + "file": "", + "line": 19 + } + } + } + ] + } + } +} diff --git a/plantuml/parser/integration_test/sequence_diagram/sequence_loop_node/sequence_loop_node.puml b/plantuml/parser/integration_test/sequence_diagram/sequence_loop_node/sequence_loop_node.puml new file mode 100644 index 00000000..661a2df2 --- /dev/null +++ b/plantuml/parser/integration_test/sequence_diagram/sequence_loop_node/sequence_loop_node.puml @@ -0,0 +1,23 @@ +' ******************************************************************************* +' Copyright (c) 2026 Contributors to the Eclipse Foundation +' +' See the NOTICE file(s) distributed with this work for additional +' information regarding copyright ownership. +' +' This program and the accompanying materials are made available under the +' terms of the Apache License Version 2.0 which is available at +' https://www.apache.org/licenses/LICENSE-2.0 +' +' SPDX-License-Identifier: Apache-2.0 +' ******************************************************************************* + +@startuml sequence_loop_node + +participant Client +participant Service + +loop retry while pending + Client -> Service : poll() +end + +@enduml diff --git a/plantuml/parser/integration_test/sequence_diagram/sequence_parallel_node/output.json b/plantuml/parser/integration_test/sequence_diagram/sequence_parallel_node/output.json new file mode 100644 index 00000000..ba54bb2d --- /dev/null +++ b/plantuml/parser/integration_test/sequence_diagram/sequence_parallel_node/output.json @@ -0,0 +1,91 @@ +{ + "sequence_parallel_node.puml": { + "name": "sequence_parallel_node", + "participants": [ + { + "display_name": "Service", + "alias": null, + "participant_type": "Participant", + "source_location": { + "file": "", + "line": 16 + }, + "stereotype": null + }, + { + "display_name": "Worker", + "alias": null, + "participant_type": "Participant", + "source_location": { + "file": "", + "line": 17 + }, + "stereotype": null + }, + { + "display_name": "Audit", + "alias": null, + "participant_type": "Participant", + "source_location": { + "file": "", + "line": 18 + }, + "stereotype": null + } + ], + "root": { + "items": [ + { + "Parallel": { + "branches": [ + { + "label": "primary path", + "block": { + "items": [ + { + "Interaction": { + "sender": "Service", + "receiver": "Worker", + "message": "dispatch()", + "source_location": { + "file": "", + "line": 21 + } + } + } + ] + }, + "source_location": { + "file": "", + "line": 20 + } + }, + { + "label": "audit path", + "block": { + "items": [ + { + "Interaction": { + "sender": "Service", + "receiver": "Audit", + "message": "record()", + "source_location": { + "file": "", + "line": 23 + } + } + } + ] + }, + "source_location": { + "file": "", + "line": 22 + } + } + ] + } + } + ] + } + } +} diff --git a/plantuml/parser/integration_test/sequence_diagram/sequence_parallel_node/sequence_parallel_node.puml b/plantuml/parser/integration_test/sequence_diagram/sequence_parallel_node/sequence_parallel_node.puml new file mode 100644 index 00000000..e8b27aa7 --- /dev/null +++ b/plantuml/parser/integration_test/sequence_diagram/sequence_parallel_node/sequence_parallel_node.puml @@ -0,0 +1,26 @@ +' ******************************************************************************* +' Copyright (c) 2026 Contributors to the Eclipse Foundation +' +' See the NOTICE file(s) distributed with this work for additional +' information regarding copyright ownership. +' +' This program and the accompanying materials are made available under the +' terms of the Apache License Version 2.0 which is available at +' https://www.apache.org/licenses/LICENSE-2.0 +' +' SPDX-License-Identifier: Apache-2.0 +' ******************************************************************************* + +@startuml sequence_parallel_node + +participant Service +participant Worker +participant Audit + +par primary path + Service -> Worker : dispatch() +else audit path + Service -> Audit : record() +end + +@enduml diff --git a/plantuml/parser/integration_test/sequence_diagram/sequence_reference_node/output.json b/plantuml/parser/integration_test/sequence_diagram/sequence_reference_node/output.json new file mode 100644 index 00000000..7c2ac1f7 --- /dev/null +++ b/plantuml/parser/integration_test/sequence_diagram/sequence_reference_node/output.json @@ -0,0 +1,44 @@ +{ + "sequence_reference_node.puml": { + "name": "sequence_reference_node", + "participants": [ + { + "display_name": "Service", + "alias": null, + "participant_type": "Participant", + "source_location": { + "file": "", + "line": 16 + }, + "stereotype": null + }, + { + "display_name": "Worker", + "alias": null, + "participant_type": "Participant", + "source_location": { + "file": "", + "line": 17 + }, + "stereotype": null + } + ], + "root": { + "items": [ + { + "Reference": { + "participants": [ + "Service", + "Worker" + ], + "text": "shared behavior", + "source_location": { + "file": "", + "line": 19 + } + } + } + ] + } + } +} diff --git a/plantuml/parser/integration_test/sequence_diagram/sequence_reference_node/sequence_reference_node.puml b/plantuml/parser/integration_test/sequence_diagram/sequence_reference_node/sequence_reference_node.puml new file mode 100644 index 00000000..54a86c6a --- /dev/null +++ b/plantuml/parser/integration_test/sequence_diagram/sequence_reference_node/sequence_reference_node.puml @@ -0,0 +1,21 @@ +' ******************************************************************************* +' Copyright (c) 2026 Contributors to the Eclipse Foundation +' +' See the NOTICE file(s) distributed with this work for additional +' information regarding copyright ownership. +' +' This program and the accompanying materials are made available under the +' terms of the Apache License Version 2.0 which is available at +' https://www.apache.org/licenses/LICENSE-2.0 +' +' SPDX-License-Identifier: Apache-2.0 +' ******************************************************************************* + +@startuml sequence_reference_node + +participant Service +participant Worker + +ref over Service, Worker : shared behavior + +@enduml diff --git a/plantuml/parser/integration_test/sequence_diagram/simple_sequence/output.json b/plantuml/parser/integration_test/sequence_diagram/simple_sequence/output.json index 3e621248..b836c2f1 100644 --- a/plantuml/parser/integration_test/sequence_diagram/simple_sequence/output.json +++ b/plantuml/parser/integration_test/sequence_diagram/simple_sequence/output.json @@ -33,365 +33,312 @@ "stereotype": "component" } ], - "root_interactions": [ - { - "event": { + "root": { + "items": [ + { "Interaction": { - "caller": "ComponentA", - "callee": "ComponentB", - "method": "callMethod1()" - } - }, - "source_location": { - "file": "", - "line": 20 - }, - "branches_node": [ - { - "event": { - "Condition": { - "condition_type": "Alt", - "condition_value": "condition1" - } - }, + "sender": "ComponentA", + "receiver": "ComponentB", + "message": "callMethod1()", "source_location": { "file": "", - "line": 21 - }, - "branches_node": [ - { - "event": { - "Return": { - "caller": "ComponentA", - "callee": "ComponentB", - "return_content": "Return Result" - } - }, - "source_location": { - "file": "", - "line": 22 - }, - "branches_node": [] - } - ] - }, - { - "event": { - "Condition": { - "condition_type": "Else", - "condition_value": "" - } - }, - "source_location": { - "file": "", - "line": 23 - }, - "branches_node": [ + "line": 20 + } + } + }, + { + "Branch": { + "cases": [ { - "event": { - "Interaction": { - "caller": "ComponentB", - "callee": "ComponentC", - "method": "method2(1)" - } + "condition": "condition1", + "block": { + "items": [ + { + "Interaction": { + "sender": "ComponentB", + "receiver": "ComponentA", + "message": "Return Result", + "source_location": { + "file": "", + "line": 22 + } + } + } + ] }, "source_location": { "file": "", - "line": 24 - }, - "branches_node": [ - { - "event": { - "Return": { - "caller": "ComponentB", - "callee": "ComponentC", - "return_content": "Return Result" - } - }, - "source_location": { - "file": "", - "line": 25 - }, - "branches_node": [] - } - ] + "line": 21 + } }, { - "event": { - "Condition": { - "condition_type": "Loop", - "condition_value": "for i = 0; i < 3; ++i" - } - }, - "source_location": { - "file": "", - "line": 27 - }, - "branches_node": [ - { - "event": { + "condition": null, + "block": { + "items": [ + { "Interaction": { - "caller": "ComponentB", - "callee": "ComponentC", - "method": "method2(i)" - } - }, - "source_location": { - "file": "", - "line": 28 - }, - "branches_node": [ - { - "event": { - "Return": { - "caller": "ComponentB", - "callee": "ComponentC", - "return_content": "Return Result" - } - }, + "sender": "ComponentB", + "receiver": "ComponentC", + "message": "method2(1)", "source_location": { "file": "", - "line": 29 - }, - "branches_node": [] + "line": 24 + } } - ] - }, - { - "event": { - "Condition": { - "condition_type": "Alt", - "condition_value": "innerConditionA" - } - }, - "source_location": { - "file": "", - "line": 30 }, - "branches_node": [ - { - "event": { - "Interaction": { - "caller": "ComponentB", - "callee": "ComponentC", - "method": "method2(extra)" - } - }, + { + "Interaction": { + "sender": "ComponentC", + "receiver": "ComponentB", + "message": "Return Result", "source_location": { "file": "", - "line": 31 - }, - "branches_node": [ - { - "event": { - "Return": { - "caller": "ComponentB", - "callee": "ComponentC", - "return_content": "Return Result" + "line": 25 + } + } + }, + { + "Loop": { + "condition": "for i = 0; i < 3; ++i", + "block": { + "items": [ + { + "Interaction": { + "sender": "ComponentB", + "receiver": "ComponentC", + "message": "method2(i)", + "source_location": { + "file": "", + "line": 28 + } } }, - "source_location": { - "file": "", - "line": 32 + { + "Interaction": { + "sender": "ComponentC", + "receiver": "ComponentB", + "message": "Return Result", + "source_location": { + "file": "", + "line": 29 + } + } }, - "branches_node": [] - } - ] - } - ] - } - ] - }, - { - "event": { - "Condition": { - "condition_type": "Loop", - "condition_value": "while count > 0" - } - }, - "source_location": { - "file": "", - "line": 36 - }, - "branches_node": [ - { - "event": { - "Interaction": { - "caller": "ComponentB", - "callee": "ComponentC", - "method": "method2(count)" - } - }, - "source_location": { - "file": "", - "line": 37 - }, - "branches_node": [ - { - "event": { - "Return": { - "caller": "ComponentB", - "callee": "ComponentC", - "return_content": "Return Result" - } + { + "Branch": { + "cases": [ + { + "condition": "innerConditionA", + "block": { + "items": [ + { + "Interaction": { + "sender": "ComponentB", + "receiver": "ComponentC", + "message": "method2(extra)", + "source_location": { + "file": "", + "line": 31 + } + } + }, + { + "Interaction": { + "sender": "ComponentC", + "receiver": "ComponentB", + "message": "Return Result", + "source_location": { + "file": "", + "line": 32 + } + } + } + ] + }, + "source_location": { + "file": "", + "line": 30 + } + } + ] + } + } + ] }, "source_location": { "file": "", - "line": 38 - }, - "branches_node": [] - } - ] - }, - { - "event": { - "Condition": { - "condition_type": "Alt", - "condition_value": "innerConditionB" + "line": 27 + } } }, - "source_location": { - "file": "", - "line": 39 - }, - "branches_node": [ - { - "event": { - "Interaction": { - "caller": "ComponentB", - "callee": "ComponentC", - "method": "method2(fallback)" - } - }, - "source_location": { - "file": "", - "line": 40 - }, - "branches_node": [ - { - "event": { - "Return": { - "caller": "ComponentB", - "callee": "ComponentC", - "return_content": "Return Result" + { + "Loop": { + "condition": "while count > 0", + "block": { + "items": [ + { + "Interaction": { + "sender": "ComponentB", + "receiver": "ComponentC", + "message": "method2(count)", + "source_location": { + "file": "", + "line": 37 + } } }, - "source_location": { - "file": "", - "line": 41 + { + "Interaction": { + "sender": "ComponentC", + "receiver": "ComponentB", + "message": "Return Result", + "source_location": { + "file": "", + "line": 38 + } + } }, - "branches_node": [] - } - ] - } - ] - }, - { - "event": { - "Condition": { - "condition_type": "Else", - "condition_value": "" - } - }, - "source_location": { - "file": "", - "line": 42 - }, - "branches_node": [ - { - "event": { - "Interaction": { - "caller": "ComponentB", - "callee": "ComponentC", - "method": "method2(default)" - } + { + "Branch": { + "cases": [ + { + "condition": "innerConditionB", + "block": { + "items": [ + { + "Interaction": { + "sender": "ComponentB", + "receiver": "ComponentC", + "message": "method2(fallback)", + "source_location": { + "file": "", + "line": 40 + } + } + }, + { + "Interaction": { + "sender": "ComponentC", + "receiver": "ComponentB", + "message": "Return Result", + "source_location": { + "file": "", + "line": 41 + } + } + } + ] + }, + "source_location": { + "file": "", + "line": 39 + } + }, + { + "condition": null, + "block": { + "items": [ + { + "Interaction": { + "sender": "ComponentB", + "receiver": "ComponentC", + "message": "method2(default)", + "source_location": { + "file": "", + "line": 43 + } + } + }, + { + "Interaction": { + "sender": "ComponentC", + "receiver": "ComponentB", + "message": "Return Result", + "source_location": { + "file": "", + "line": 44 + } + } + } + ] + }, + "source_location": { + "file": "", + "line": 42 + } + } + ] + } + } + ] }, "source_location": { "file": "", - "line": 43 - }, - "branches_node": [ + "line": 36 + } + } + }, + { + "Branch": { + "cases": [ { - "event": { - "Return": { - "caller": "ComponentB", - "callee": "ComponentC", - "return_content": "Return Result" - } + "condition": "condition2", + "block": { + "items": [ + { + "Interaction": { + "sender": "ComponentB", + "receiver": "ComponentC", + "message": "method2(result)", + "source_location": { + "file": "", + "line": 49 + } + } + }, + { + "Interaction": { + "sender": "ComponentC", + "receiver": "ComponentB", + "message": "Return Result", + "source_location": { + "file": "", + "line": 50 + } + } + } + ] }, "source_location": { "file": "", - "line": 44 - }, - "branches_node": [] + "line": 48 + } } ] } - ] - } - ] - }, - { - "event": { - "Condition": { - "condition_type": "Alt", - "condition_value": "condition2" - } - }, - "source_location": { - "file": "", - "line": 48 - }, - "branches_node": [ - { - "event": { - "Interaction": { - "caller": "ComponentB", - "callee": "ComponentC", - "method": "method2(result)" - } }, - "source_location": { - "file": "", - "line": 49 - }, - "branches_node": [ - { - "event": { - "Return": { - "caller": "ComponentB", - "callee": "ComponentC", - "return_content": "Return Result" - } - }, + { + "Interaction": { + "sender": "ComponentB", + "receiver": "ComponentA", + "message": "Return Result", "source_location": { "file": "", - "line": 50 - }, - "branches_node": [] + "line": 53 + } } - ] - } - ] - }, - { - "event": { - "Return": { - "caller": "ComponentA", - "callee": "ComponentB", - "return_content": "Return Result" - } + } + ] }, "source_location": { "file": "", - "line": 53 - }, - "branches_node": [] + "line": 23 + } } ] } - ] - } - ] + } + ] + } } } diff --git a/plantuml/parser/integration_test/src/test_error_view.rs b/plantuml/parser/integration_test/src/test_error_view.rs index c21f4cd9..d262ea9e 100644 --- a/plantuml/parser/integration_test/src/test_error_view.rs +++ b/plantuml/parser/integration_test/src/test_error_view.rs @@ -345,6 +345,41 @@ impl ErrorView for ClassPumlResolverError { impl ErrorView for SequenceResolverError { fn project(&self, base_dir: &Path) -> ProjectedError { let _ = base_dir; - match *self {} + match self { + SequenceResolverError::DestroyedParticipantUse { + participant, + source_location, + } => ProjectedError::new("DestroyedParticipantUse") + .with_field("participant", participant.clone()) + .with_field("line", source_location.line.to_string()), + SequenceResolverError::InvalidMessageDirection { + arrow, + source_location, + } => ProjectedError::new("InvalidMessageDirection") + .with_field("arrow", arrow.clone()) + .with_field("line", source_location.line.to_string()), + SequenceResolverError::UnterminatedGroup { source_location } => { + ProjectedError::new("UnterminatedGroup") + .with_field("line", source_location.line.to_string()) + } + SequenceResolverError::ElseNotAllowedInGroup { + kind, + source_location, + } => ProjectedError::new("ElseNotAllowedInGroup") + .with_field("kind", format!("{kind:?}")) + .with_field("line", source_location.line.to_string()), + SequenceResolverError::ElseOutsideGroup { source_location } => { + ProjectedError::new("ElseOutsideGroup") + .with_field("line", source_location.line.to_string()) + } + SequenceResolverError::MismatchedGroupEnd { + expected, + found, + source_location, + } => ProjectedError::new("MismatchedGroupEnd") + .with_field("expected", format!("{expected:?}")) + .with_field("found", format!("{found:?}")) + .with_field("line", source_location.line.to_string()), + } } } diff --git a/plantuml/parser/puml_idmap/src/lib.rs b/plantuml/parser/puml_idmap/src/lib.rs index 6b5bca94..c8360081 100644 --- a/plantuml/parser/puml_idmap/src/lib.rs +++ b/plantuml/parser/puml_idmap/src/lib.rs @@ -289,33 +289,7 @@ fn class_model_to_idmap(model: &ClassDiagram, source: &str) -> IdMapFile { /// Collect the unique participant names from a sequence tree. fn collect_participants(tree: &SequenceTree) -> HashSet { - use sequence_logic::{Event, SequenceNode}; - - fn walk_nodes(nodes: &[SequenceNode], out: &mut HashSet) { - for node in nodes { - match &node.event { - Event::Interaction(i) => { - out.insert(i.caller.clone()); - out.insert(i.callee.clone()); - } - Event::Return(r) => { - out.insert(r.caller.clone()); - out.insert(r.callee.clone()); - } - Event::Condition(_) => {} - } - walk_nodes(&node.branches_node, out); - } - } - - let mut participants = HashSet::new(); - walk_nodes(&tree.root_interactions, &mut participants); - - for p in &tree.participants { - participants.insert(p.alias.clone().unwrap_or_else(|| p.display_name.clone())); - } - - participants + tree.participant_reference_names().collect() } /// Produce an [`IdMapFile`] from a resolved sequence diagram. @@ -504,7 +478,37 @@ mod tests { use class_diagram::{MemberVariable, Method, RelationType, Relationship, SimpleEntity}; use component_diagram::{ComponentType, SourceLocation}; use puml_fta::FtaNode; - use sequence_logic::{Event, Interaction, SequenceNode}; + use sequence_logic::{Block, Interaction, Node, ParticipantType, SequenceParticipant}; + + fn sequence_interaction(sender: &str, receiver: &str) -> Node { + Node::Interaction(Interaction { + sender: Some(sender.to_string().into()), + receiver: Some(receiver.to_string().into()), + message: Some("call".to_string()), + source_location: SourceLocation::new("test.puml", 0), + }) + } + + fn sequence_tree(participants: &[&str], items: Vec) -> SequenceTree { + SequenceTree { + name: None, + participants: participants + .iter() + .map(|name| sequence_participant(name)) + .collect(), + root: Block { items }, + } + } + + fn sequence_participant(name: &str) -> SequenceParticipant { + SequenceParticipant { + display_name: name.to_string(), + alias: None, + participant_type: ParticipantType::Participant, + source_location: SourceLocation::new("test.puml", 0), + stereotype: None, + } + } fn component( id: &str, @@ -686,20 +690,13 @@ mod tests { #[test] fn sequence_participants_become_sorted_references() { - let interaction = |caller: &str, callee: &str| SequenceNode { - event: Event::Interaction(Interaction { - caller: caller.to_string(), - callee: callee.to_string(), - method: "call".to_string(), - }), - source_location: SourceLocation::new("test.puml", 0), - branches_node: Vec::new(), - }; - let tree = SequenceTree { - name: None, - participants: Vec::new(), - root_interactions: vec![interaction("Zebra", "Alpha"), interaction("Alpha", "Mango")], - }; + let tree = sequence_tree( + &["Zebra", "Alpha", "Mango"], + vec![ + sequence_interaction("Zebra", "Alpha"), + sequence_interaction("Alpha", "Mango"), + ], + ); let idmap = sequence_model_to_idmap(&tree, "pkg/seq.puml"); @@ -1162,20 +1159,10 @@ mod tests { #[test] fn write_idmap_to_file_writes_sequence_dispatch_to_disk() { let dir = unique_tmp_dir("write_sequence"); - let interaction = SequenceNode { - event: Event::Interaction(Interaction { - caller: "Alpha".to_string(), - callee: "Beta".to_string(), - method: "call".to_string(), - }), - source_location: SourceLocation::new("test.puml", 0), - branches_node: Vec::new(), - }; - let tree = SequenceTree { - name: None, - participants: Vec::new(), - root_interactions: vec![interaction], - }; + let tree = sequence_tree( + &["Alpha", "Beta"], + vec![sequence_interaction("Alpha", "Beta")], + ); let input = Path::new("some/dir/seq.puml"); let output = write_idmap_to_file( @@ -1273,43 +1260,16 @@ mod tests { // ── Sequence participant traversal ───────────────────────────────────── #[test] - fn sequence_collect_participants_traverses_returns_and_nested_branches() { - use sequence_logic::{Condition, ConditionType, Return}; - - // Deeply nested interaction inside a control block (Condition), which - // itself sits inside a Return node's branches. `collect_participants` - // must recurse through both and pick up Deep/Nested, plus the Return's - // own caller/callee (A/B). Condition contributes no participant itself. - let deep = SequenceNode { - event: Event::Interaction(Interaction { - caller: "Deep".to_string(), - callee: "Nested".to_string(), - method: "call".to_string(), - }), - source_location: SourceLocation::new("test.puml", 0), - branches_node: Vec::new(), - }; - let control_block = SequenceNode { - event: Event::Condition(Condition { - condition_type: ConditionType::Alt, - condition_value: "ok?".to_string(), - }), - source_location: SourceLocation::new("test.puml", 0), - branches_node: vec![deep], - }; - let ret = SequenceNode { - event: Event::Return(Return { - caller: "B".to_string(), - callee: "A".to_string(), - return_content: "ok".to_string(), - }), - source_location: SourceLocation::new("test.puml", 0), - branches_node: vec![control_block], - }; + fn sequence_collect_participants_from_sequence_tree() { let tree = SequenceTree { name: None, - participants: Vec::new(), - root_interactions: vec![ret], + participants: vec![ + sequence_participant("B"), + sequence_participant("A"), + sequence_participant("Deep"), + sequence_participant("Nested"), + ], + root: Block::default(), }; let idmap = sequence_model_to_idmap(&tree, "pkg/seq.puml"); @@ -1321,30 +1281,16 @@ mod tests { #[test] fn sequence_declared_but_unused_participant_is_still_a_reference() { - // `Idle` is declared via a `participant` statement but never appears - // as a caller/callee in any message, so the interaction walk alone - // would miss it. `collect_participants` must merge in `tree.participants` - // to still emit it as a reference. - let interaction = SequenceNode { - event: Event::Interaction(Interaction { - caller: "Alice".to_string(), - callee: "Bob".to_string(), - method: "call".to_string(), - }), - source_location: SourceLocation::new("test.puml", 0), - branches_node: Vec::new(), - }; - let declared_idle = sequence_logic::SequenceParticipant { - display_name: "Idle".to_string(), - alias: None, - participant_type: sequence_logic::ParticipantType::Participant, - source_location: SourceLocation::new("test.puml", 0), - stereotype: None, - }; let tree = SequenceTree { name: None, - participants: vec![declared_idle], - root_interactions: vec![interaction], + participants: vec![ + sequence_participant("Alice"), + sequence_participant("Bob"), + sequence_participant("Idle"), + ], + root: Block { + items: vec![sequence_interaction("Alice", "Bob")], + }, }; let idmap = sequence_model_to_idmap(&tree, "pkg/seq.puml"); diff --git a/plantuml/parser/puml_resolver/src/sequence_diagram/BUILD b/plantuml/parser/puml_resolver/src/sequence_diagram/BUILD index e181a2ee..c488d0ea 100644 --- a/plantuml/parser/puml_resolver/src/sequence_diagram/BUILD +++ b/plantuml/parser/puml_resolver/src/sequence_diagram/BUILD @@ -15,9 +15,13 @@ load("@rules_rust//rust:defs.bzl", "rust_library", "rust_test") rust_library( name = "puml_resolver_sequence", srcs = [ + "src/error.rs", "src/lib.rs", - "src/logic_parser.rs", + "src/lifecycle_validator.rs", + "src/participant_table.rs", "src/sequence_resolver.rs", + "src/sequence_tree_builder.rs", + "src/statement_resolver.rs", ], crate_name = "sequence_resolver", crate_root = "src/lib.rs", @@ -30,6 +34,7 @@ rust_library( "@crates//:log", "@crates//:serde", "@crates//:serde_json", + "@crates//:thiserror", ], ) diff --git a/plantuml/parser/puml_resolver/src/sequence_diagram/src/error.rs b/plantuml/parser/puml_resolver/src/sequence_diagram/src/error.rs new file mode 100644 index 00000000..340f9048 --- /dev/null +++ b/plantuml/parser/puml_resolver/src/sequence_diagram/src/error.rs @@ -0,0 +1,52 @@ +// ******************************************************************************* +// Copyright (c) 2026 Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// This program and the accompanying materials are made available under the +// terms of the Apache License Version 2.0 which is available at +// +// +// SPDX-License-Identifier: Apache-2.0 +// ******************************************************************************* + +use sequence_logic::SourceLocation; + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum SequenceResolverError { + #[error("participant `{participant}` is used after destroy at {source_location}")] + DestroyedParticipantUse { + participant: String, + source_location: SourceLocation, + }, + + #[error( + "invalid sequence message arrow `{arrow}` at {source_location}: exactly one directional arrowhead is required" + )] + InvalidMessageDirection { + arrow: String, + source_location: SourceLocation, + }, + + #[error("unterminated sequence group at {source_location}, add 'end' to close the group")] + UnterminatedGroup { source_location: SourceLocation }, + + #[error("'else' is not valid in {kind:?} ({source_location}), only supported in alt and par")] + ElseNotAllowedInGroup { + kind: sequence_parser::sequence_ast::GroupKind, + source_location: SourceLocation, + }, + + #[error("'else' is not valid outside a sequence group ({source_location})")] + ElseOutsideGroup { source_location: SourceLocation }, + + #[error( + "group end kind {found:?} does not match start kind {expected:?} at {source_location}" + )] + MismatchedGroupEnd { + expected: sequence_parser::sequence_ast::GroupKind, + found: sequence_parser::sequence_ast::GroupKind, + source_location: SourceLocation, + }, +} diff --git a/plantuml/parser/puml_resolver/src/sequence_diagram/src/lib.rs b/plantuml/parser/puml_resolver/src/sequence_diagram/src/lib.rs index b5696747..d7719710 100644 --- a/plantuml/parser/puml_resolver/src/sequence_diagram/src/lib.rs +++ b/plantuml/parser/puml_resolver/src/sequence_diagram/src/lib.rs @@ -11,7 +11,12 @@ // SPDX-License-Identifier: Apache-2.0 // ******************************************************************************* -pub mod logic_parser; +mod error; +mod lifecycle_validator; +mod participant_table; mod sequence_resolver; +mod sequence_tree_builder; +mod statement_resolver; -pub use sequence_resolver::{SequenceResolver, SequenceResolverError}; +pub use error::SequenceResolverError; +pub use sequence_resolver::SequenceResolver; diff --git a/plantuml/parser/puml_resolver/src/sequence_diagram/src/lifecycle_validator.rs b/plantuml/parser/puml_resolver/src/sequence_diagram/src/lifecycle_validator.rs new file mode 100644 index 00000000..9333b5c3 --- /dev/null +++ b/plantuml/parser/puml_resolver/src/sequence_diagram/src/lifecycle_validator.rs @@ -0,0 +1,159 @@ +// ******************************************************************************* +// Copyright (c) 2026 Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// This program and the accompanying materials are made available under the +// terms of the Apache License Version 2.0 which is available at +// +// +// SPDX-License-Identifier: Apache-2.0 +// ******************************************************************************* + +use std::collections::HashSet; + +use crate::error::SequenceResolverError; +use sequence_logic::{Block, LifecycleAction, Node, ParticipantId, SourceLocation}; + +pub(crate) fn validate_lifecycle_consistency(root: &Block) -> Result<(), SequenceResolverError> { + let mut destroyed = HashSet::new(); + validate_block(root, &mut destroyed) +} + +fn validate_block( + block: &Block, + destroyed: &mut HashSet, +) -> Result<(), SequenceResolverError> { + for node in &block.items { + match node { + Node::Interaction(interaction) => { + if let Some(sender) = &interaction.sender { + ensure_not_destroyed(sender, &interaction.source_location, destroyed)?; + } + if let Some(receiver) = &interaction.receiver { + ensure_not_destroyed(receiver, &interaction.source_location, destroyed)?; + } + } + Node::Reference(reference) => { + for participant in &reference.participants { + ensure_not_destroyed(participant, &reference.source_location, destroyed)?; + } + } + Node::Lifecycle(lifecycle) => match lifecycle.action { + LifecycleAction::Create => { + destroyed.remove(&lifecycle.participant); + } + LifecycleAction::Destroy => { + destroyed.insert(lifecycle.participant.clone()); + } + LifecycleAction::Activate | LifecycleAction::Deactivate => { + ensure_not_destroyed( + &lifecycle.participant, + &lifecycle.source_location, + destroyed, + )?; + } + }, + Node::Branch(branch) => { + for case in &branch.cases { + validate_child_block(&case.block, destroyed)?; + } + } + Node::Loop(loop_node) => validate_child_block(&loop_node.block, destroyed)?, + Node::Parallel(parallel) => { + for branch in ¶llel.branches { + validate_child_block(&branch.block, destroyed)?; + } + } + Node::EarlyExit(early_exit) => validate_child_block(&early_exit.block, destroyed)?, + } + } + + Ok(()) +} + +fn validate_child_block( + block: &Block, + destroyed: &HashSet, +) -> Result<(), SequenceResolverError> { + let mut scoped_destroyed = destroyed.clone(); + validate_block(block, &mut scoped_destroyed) +} + +fn ensure_not_destroyed( + participant: &str, + source_location: &SourceLocation, + destroyed: &HashSet, +) -> Result<(), SequenceResolverError> { + if destroyed.contains(participant) { + return Err(SequenceResolverError::DestroyedParticipantUse { + participant: participant.to_string(), + source_location: source_location.clone(), + }); + } + + Ok(()) +} + +#[cfg(test)] +mod lifecycle_validator_tests { + use super::*; + use sequence_logic::{Block, Interaction, ParticipantLifecycle}; + + fn dummy_source_location() -> SourceLocation { + SourceLocation::new("test.puml", 0) + } + + fn interaction(sender: &str, receiver: &str) -> Node { + Node::Interaction(Interaction { + sender: Some(sender.to_string().into()), + receiver: Some(receiver.to_string().into()), + message: Some("message".to_string()), + source_location: dummy_source_location(), + }) + } + + fn lifecycle(participant: &str, action: LifecycleAction) -> Node { + Node::Lifecycle(ParticipantLifecycle { + participant: participant.to_string().into(), + action, + source_location: dummy_source_location(), + }) + } + + #[test] + fn test_destroyed_participant_cannot_be_used_later() { + let block = Block { + items: vec![ + interaction("A", "B"), + lifecycle("B", LifecycleAction::Destroy), + interaction("A", "B"), + ], + }; + + let err = validate_lifecycle_consistency(&block) + .expect_err("destroyed participant use must fail"); + assert_eq!( + err, + SequenceResolverError::DestroyedParticipantUse { + participant: "B".to_string(), + source_location: dummy_source_location(), + } + ); + } + + #[test] + fn test_create_restores_destroyed_participant() { + let block = Block { + items: vec![ + interaction("A", "B"), + lifecycle("B", LifecycleAction::Destroy), + lifecycle("B", LifecycleAction::Create), + interaction("A", "B"), + ], + }; + + validate_lifecycle_consistency(&block).expect("create makes participant usable again"); + } +} diff --git a/plantuml/parser/puml_resolver/src/sequence_diagram/src/logic_parser.rs b/plantuml/parser/puml_resolver/src/sequence_diagram/src/logic_parser.rs deleted file mode 100644 index f41d0e46..00000000 --- a/plantuml/parser/puml_resolver/src/sequence_diagram/src/logic_parser.rs +++ /dev/null @@ -1,339 +0,0 @@ -// ******************************************************************************* -// Copyright (c) 2026 Contributors to the Eclipse Foundation -// -// See the NOTICE file(s) distributed with this work for additional -// information regarding copyright ownership. -// -// This program and the accompanying materials are made available under the -// terms of the Apache License Version 2.0 which is available at -// -// -// SPDX-License-Identifier: Apache-2.0 -// ******************************************************************************* -//! Logic parser module for constructing and visualizing sequence node trees - -use sequence_logic::*; -use sequence_parser::*; - -const EXTERNAL_ENDPOINT_NAME: &str = "ExternalEndpoint"; - -fn endpoint_name(endpoint: &MessageEndpoint) -> String { - match endpoint { - MessageEndpoint::Participant(identifier) => identifier - .alias - .as_deref() - .unwrap_or(&identifier.display_name) - .to_string(), - MessageEndpoint::LostFound(_) => EXTERNAL_ENDPOINT_NAME.to_string(), - } -} - -/// Convert a syntax-level `GroupKind` into the metamodel `ConditionType`. -fn group_kind_to_condition(kind: &GroupKind) -> ConditionType { - match kind { - GroupKind::Opt => ConditionType::Opt, - GroupKind::Alt => ConditionType::Alt, - GroupKind::Loop => ConditionType::Loop, - GroupKind::Par => ConditionType::Par, - GroupKind::Break => ConditionType::Break, - GroupKind::Critical => ConditionType::Critical, - GroupKind::Group => ConditionType::Group, - } -} - -/// Build a tree of SequenceNodes from a list of statements -pub fn build_tree(statements: &[Statement]) -> Vec { - let mut nodes = Vec::new(); - let mut i = 0; - - while i < statements.len() { - if let Some((node, consumed)) = build_node(&statements[i..]) { - nodes.push(node); - i += consumed; - } else { - // Skip over branch/end markers that are not handled - if let Some(Statement::GroupCmd(g)) = statements.get(i) { - if matches!(g, GroupCmd::Else(_) | GroupCmd::End(_)) { - i += 1; - continue; - } - } - i += 1; - } - } - - nodes -} - -/// Helper function to box sequence nodes -pub(crate) fn box_nodes(nodes: Vec) -> Vec { - nodes -} - -fn is_group_node(group: &GroupCmd) -> bool { - matches!(group, GroupCmd::Start(_) | GroupCmd::Else(_)) -} - -fn collect_group_statements(statements: &[Statement]) -> (Vec, usize) { - let mut group_statements = Vec::new(); - let mut consumed = 1; - let mut nesting_depth = 0; - - for stmt in &statements[1..] { - if let Statement::GroupCmd(group) = stmt { - match group { - GroupCmd::End(_) => { - if nesting_depth > 0 { - nesting_depth -= 1; - group_statements.push(stmt.clone()); - } else { - break; - } - } - GroupCmd::Else(_) => { - if nesting_depth > 0 { - group_statements.push(stmt.clone()); - } else { - break; - } - } - GroupCmd::Start(_) => { - nesting_depth += 1; - group_statements.push(stmt.clone()); - } - } - } else { - group_statements.push(stmt.clone()); - } - consumed += 1; - } - - (group_statements, consumed) -} - -fn build_group_node(statements: &[Statement], group: &GroupCmd) -> (SequenceNode, usize) { - let (condition, source_location) = group_condition_and_location(group); - let (group_statements, consumed) = collect_group_statements(statements); - - ( - SequenceNode { - event: Event::Condition(condition), - source_location, - branches_node: box_nodes(build_tree(&group_statements)), - }, - consumed, - ) -} - -fn group_condition_and_location(group: &GroupCmd) -> (Condition, SourceLocation) { - match group { - GroupCmd::Start(start) => ( - Condition { - condition_type: group_kind_to_condition(&start.kind), - condition_value: start.label.clone().unwrap_or_default(), - }, - start.source_location.clone(), - ), - GroupCmd::Else(else_cmd) => ( - Condition { - condition_type: ConditionType::Else, - condition_value: else_cmd.label.clone().unwrap_or_default(), - }, - else_cmd.source_location.clone(), - ), - GroupCmd::End(end) => ( - Condition { - condition_type: ConditionType::End, - condition_value: String::new(), - }, - end.source_location.clone(), - ), - } -} - -/// Build a single sequence node and return how many statements were consumed -fn build_node(statements: &[Statement]) -> Option<(SequenceNode, usize)> { - if statements.is_empty() { - return None; - } - - match &statements[0] { - Statement::Message(msg) => { - // Determine if this is an Interaction or Return based on arrow - let event = message_to_event(msg)?; - - // For interactions, collect child nodes until we hit the matching return - let mut branches = Vec::new(); - let mut consumed = 1; - - if let Event::Interaction(ref interaction) = event { - // Look ahead for nested content and the matching return - let caller = &interaction.caller; - let callee = &interaction.callee; - let mut found_return = false; - let mut i = 1; - - while i < statements.len() { - match &statements[i] { - Statement::Message(m) => { - // Check if this is the matching return - if is_return_arrow(m) { - if let Some(Event::Return(ret)) = message_to_event(m) { - if &ret.caller == caller && &ret.callee == callee { - // Found our return - add it as the last branch node - branches.push(SequenceNode { - event: Event::Return(ret), - source_location: m.source_location.clone(), - branches_node: Vec::new(), - }); - consumed = i + 1; - found_return = true; - break; - } - } - } - - // Not our return, process it as a child node - if let Some((child_node, child_consumed)) = build_node(&statements[i..]) - { - branches.push(child_node); - i += child_consumed; - } else { - i += 1; - } - } - Statement::GroupCmd(_group) => { - // Process branches (alt/else/opt/loop) - if let Some((branch_node, branch_consumed)) = - build_node(&statements[i..]) - { - branches.push(branch_node); - i += branch_consumed; - } else { - i += 1; - } - } - _ => { - i += 1; - } - } - } - - // If no matching return found, we still consumed what we collected - if !found_return { - consumed = i; - } - } - - Some(( - SequenceNode { - event, - source_location: msg.source_location.clone(), - branches_node: branches, - }, - consumed, - )) - } - Statement::GroupCmd(group) => { - // Handle group commands (alt, opt, loop, else, etc.) - match group { - GroupCmd::End(_) => None, // End markers signal the close of a branch - _ if is_group_node(group) => Some(build_group_node(statements, group)), - _ => None, - } - } - _ => None, // Skip non-message, non-group statements - } -} - -/// Convert a message statement to an Event (Interaction or Return) -fn message_to_event(msg: &Message) -> Option { - let method = msg.description.clone().unwrap_or_default(); - - // Check if arrow left decorator points left (reverse arrow like <--) - let is_reverse = msg - .arrow - .left - .as_ref() - .map(|d| d.raw.contains("<")) - .unwrap_or(false); - - // Determine actual caller and callee based on arrow direction. - let (actual_from, actual_to) = if is_reverse { - // Arrow points left: from right participant to left participant. - // "A <-- B" means B sends to A. - (endpoint_name(&msg.right), endpoint_name(&msg.left)) - } else { - // Arrow points right: from left participant to right participant. - // "A -> B" means A sends to B. - (endpoint_name(&msg.left), endpoint_name(&msg.right)) - }; - - // Check arrow type to determine Interaction vs Return. - if is_return_arrow_from_arrow(&msg.arrow) { - // For returns: actual_from is the sender (callee), actual_to is the receiver (caller). - Some(Event::Return(Return { - caller: actual_to, - callee: actual_from, - return_content: method, - })) - } else { - Some(Event::Interaction(Interaction { - caller: actual_from, - callee: actual_to, - method, - })) - } -} - -/// Check if a message represents a return arrow -fn is_return_arrow(msg: &Message) -> bool { - is_return_arrow_from_arrow(&msg.arrow) -} - -/// Check if an arrow represents a return (dashed arrow) -fn is_return_arrow_from_arrow(arrow: &Arrow) -> bool { - // Return arrows are typically dashed: "-->" - arrow.line.raw.contains("--") -} - -#[cfg(test)] -mod return_arrow_detection_tests { - use super::*; - use parser_core::common_ast::{Arrow, ArrowDecor, ArrowLine}; - - fn arrow(line: &str, right: Option<&str>) -> Arrow { - Arrow { - left: None, - line: ArrowLine { - raw: line.to_string(), - }, - middle: None, - right: right.map(|r| ArrowDecor { raw: r.to_string() }), - } - } - - /// "->" is a solid call arrow and must NOT be classified as a return. - #[test] - fn test_solid_call_arrow_is_not_return() { - assert!(!is_return_arrow_from_arrow(&arrow("-", Some(">")))); - } - - /// "-->" is a dashed return arrow and MUST be classified as a return. - #[test] - fn test_dashed_return_arrow_is_return() { - assert!(is_return_arrow_from_arrow(&arrow("--", Some(">")))); - } - - /// "->>" (solid with double-headed arrow) must NOT be classified as a return. - #[test] - fn test_solid_double_headed_arrow_is_not_return() { - assert!(!is_return_arrow_from_arrow(&arrow("-", Some(">>")))); - } - - /// "-->>" (dashed with double-headed arrow) MUST be classified as a return. - #[test] - fn test_dashed_double_headed_arrow_is_return() { - assert!(is_return_arrow_from_arrow(&arrow("--", Some(">>")))); - } -} diff --git a/plantuml/parser/puml_resolver/src/sequence_diagram/src/participant_table.rs b/plantuml/parser/puml_resolver/src/sequence_diagram/src/participant_table.rs new file mode 100644 index 00000000..854b30b6 --- /dev/null +++ b/plantuml/parser/puml_resolver/src/sequence_diagram/src/participant_table.rs @@ -0,0 +1,327 @@ +// ******************************************************************************* +// Copyright (c) 2026 Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// This program and the accompanying materials are made available under the +// terms of the Apache License Version 2.0 which is available at +// +// +// SPDX-License-Identifier: Apache-2.0 +// ******************************************************************************* + +use std::collections::HashSet; + +use sequence_logic::{ + ParticipantType as LogicParticipantType, SequenceParticipant, SourceLocation, +}; +use sequence_parser::sequence_ast::{ + CreateCmd, MessageEndpoint, ParticipantDef, ParticipantIdentifier, + ParticipantType as SyntaxParticipantType, Statement, +}; + +pub(crate) fn build_participant_table(statements: &[Statement]) -> Vec { + let mut resolved_names = HashSet::new(); + let mut participants = Vec::new(); + + add_explicit_participants(statements, &mut participants, &mut resolved_names); + + // Message endpoints and create commands must share the ordered pass so the + // first participant reference keeps its source location. + add_implicit_participants(statements, &mut participants, &mut resolved_names); + + participants +} + +fn add_explicit_participants( + statements: &[Statement], + participants: &mut Vec, + resolved_names: &mut HashSet, +) { + for stmt in statements { + if let Statement::ParticipantDef(participant_def) = stmt { + add_participant( + participants, + resolved_names, + explicit_participant(participant_def), + ); + } + } +} + +fn add_implicit_participants( + statements: &[Statement], + participants: &mut Vec, + resolved_names: &mut HashSet, +) { + for stmt in statements { + match stmt { + Statement::Message(msg) => { + add_endpoint_participant( + participants, + resolved_names, + &msg.left, + &msg.source_location, + ); + add_endpoint_participant( + participants, + resolved_names, + &msg.right, + &msg.source_location, + ); + } + Statement::CreateCmd(create_cmd) => { + add_participant( + participants, + resolved_names, + created_participant(create_cmd), + ); + } + _ => {} + } + } +} + +fn add_participant( + participants: &mut Vec, + resolved_names: &mut HashSet, + participant: SequenceParticipant, +) { + let identifier = ParticipantIdentifier { + display_name: participant.display_name.clone(), + alias: participant.alias.clone(), + }; + let reference_name = participant_reference_name(&identifier); + if reference_name.is_empty() || !resolved_names.insert(reference_name.to_string()) { + return; + } + participants.push(participant); +} + +fn add_endpoint_participant( + participants: &mut Vec, + resolved_names: &mut HashSet, + endpoint: &MessageEndpoint, + source_location: &SourceLocation, +) { + if let MessageEndpoint::Participant(identifier) = endpoint { + add_participant( + participants, + resolved_names, + implicit_participant(identifier, source_location), + ); + } +} + +fn explicit_participant(participant_def: &ParticipantDef) -> SequenceParticipant { + SequenceParticipant { + display_name: participant_def.identifier.display_name.clone(), + alias: participant_def.identifier.alias.clone(), + participant_type: map_parser_participant_type(&participant_def.participant_type), + source_location: participant_def.source_location.clone(), + stereotype: participant_def.stereotype.clone(), + } +} + +fn created_participant(create_cmd: &CreateCmd) -> SequenceParticipant { + SequenceParticipant { + display_name: create_cmd.identifier.display_name.clone(), + alias: create_cmd.identifier.alias.clone(), + participant_type: map_parser_participant_type(&create_cmd.participant_type), + source_location: create_cmd.source_location.clone(), + stereotype: create_cmd.stereotype.clone(), + } +} + +fn implicit_participant( + identifier: &ParticipantIdentifier, + source_location: &SourceLocation, +) -> SequenceParticipant { + SequenceParticipant { + display_name: identifier.display_name.clone(), + alias: identifier.alias.clone(), + participant_type: LogicParticipantType::Participant, + source_location: source_location.clone(), + stereotype: None, + } +} + +fn map_parser_participant_type(kind: &SyntaxParticipantType) -> LogicParticipantType { + match kind { + SyntaxParticipantType::Participant => LogicParticipantType::Participant, + SyntaxParticipantType::Actor => LogicParticipantType::Actor, + SyntaxParticipantType::Boundary => LogicParticipantType::Boundary, + SyntaxParticipantType::Control => LogicParticipantType::Control, + SyntaxParticipantType::Entity => LogicParticipantType::Entity, + SyntaxParticipantType::Queue => LogicParticipantType::Queue, + SyntaxParticipantType::Database => LogicParticipantType::Database, + SyntaxParticipantType::Collections => LogicParticipantType::Collections, + } +} + +pub(crate) fn participant_reference_name(identifier: &ParticipantIdentifier) -> &str { + identifier + .alias + .as_deref() + .unwrap_or(&identifier.display_name) +} + +#[cfg(test)] +mod participant_table_tests { + use super::*; + use parser_core::common_ast::{Arrow, ArrowDecor, ArrowLine}; + use sequence_parser::sequence_ast::{DestroyCmd, Message, ParticipantRef}; + + fn source(line: u32) -> SourceLocation { + SourceLocation::new("test.puml", line) + } + + fn message_endpoint(name: &str) -> MessageEndpoint { + MessageEndpoint::Participant(ParticipantIdentifier { + display_name: name.to_string(), + alias: None, + }) + } + + fn message(from: &str, to: &str, source_location: SourceLocation) -> Statement { + Statement::Message(Message { + left: message_endpoint(from), + arrow: Arrow { + left: None, + line: ArrowLine { + raw: "-".to_string(), + }, + middle: None, + right: Some(ArrowDecor { + raw: ">".to_string(), + }), + }, + right: message_endpoint(to), + suffix: None, + description: Some("message".to_string()), + source_location, + }) + } + + fn participant(name: &str) -> Statement { + Statement::ParticipantDef(ParticipantDef { + participant_type: SyntaxParticipantType::Participant, + identifier: ParticipantIdentifier { + display_name: name.to_string(), + alias: None, + }, + stereotype: None, + source_location: source(0), + }) + } + + fn participant_with_alias(display_name: &str, alias: &str) -> Statement { + Statement::ParticipantDef(ParticipantDef { + participant_type: SyntaxParticipantType::Participant, + identifier: ParticipantIdentifier { + display_name: display_name.to_string(), + alias: Some(alias.to_string()), + }, + stereotype: None, + source_location: source(0), + }) + } + + #[test] + fn declared_participants_are_preserved_without_duplicates() { + let statements = vec![ + participant("A"), + participant("B"), + message("A", "B", source(1)), + message("B", "A", source(2)), + ]; + + let participants = build_participant_table(&statements); + + assert_eq!(participants.len(), 2); + assert_eq!(participants[0].display_name, "A"); + assert_eq!(participants[1].display_name, "B"); + } + + #[test] + fn aliased_participant_reference_does_not_create_duplicate() { + let statements = vec![ + participant("A"), + participant_with_alias("Display B", "B"), + message("A", "B", source(1)), + ]; + + let participants = build_participant_table(&statements); + + assert_eq!(participants.len(), 2); + assert_eq!(participants[1].display_name, "Display B"); + assert_eq!(participants[1].alias.as_deref(), Some("B")); + } + + #[test] + fn aliased_participant_display_name_reference_creates_implicit_participant() { + let statements = vec![ + participant("A"), + participant_with_alias("Display B", "B"), + message("A", "Display B", source(1)), + ]; + + let participants = build_participant_table(&statements); + + assert_eq!(participants.len(), 3); + assert_eq!(participants[2].display_name, "Display B"); + assert_eq!(participants[2].alias, None); + } + + #[test] + fn no_participants_declared_creates_implicit_participants() { + let statements = vec![message("X", "Y", source(1))]; + + let participants = build_participant_table(&statements); + + assert_eq!(participants.len(), 2); + assert_eq!(participants[0].display_name, "X"); + assert_eq!(participants[1].display_name, "Y"); + } + + #[test] + fn message_endpoint_before_create_sets_participant_source_location() { + let message_location = source(1); + let create_location = source(2); + let statements = vec![ + message("A", "B", message_location.clone()), + Statement::CreateCmd(CreateCmd { + participant_type: SyntaxParticipantType::Participant, + identifier: ParticipantIdentifier { + display_name: "B".to_string(), + alias: None, + }, + stereotype: None, + source_location: create_location, + }), + ]; + + let participants = build_participant_table(&statements); + + assert_eq!(participants.len(), 2); + assert_eq!(participants[0].display_name, "A"); + assert_eq!(participants[0].source_location, message_location); + assert_eq!(participants[1].display_name, "B"); + assert_eq!(participants[1].source_location, message_location); + } + + #[test] + fn destroy_statement_does_not_create_implicit_participant() { + let statements = vec![Statement::DestroyCmd(DestroyCmd { + participant: ParticipantRef { + identifier: "Implicit".to_string(), + }, + source_location: source(1), + })]; + + let participants = build_participant_table(&statements); + + assert!(participants.is_empty()); + } +} diff --git a/plantuml/parser/puml_resolver/src/sequence_diagram/src/sequence_resolver.rs b/plantuml/parser/puml_resolver/src/sequence_diagram/src/sequence_resolver.rs index 5749cf9b..36f9aa65 100644 --- a/plantuml/parser/puml_resolver/src/sequence_diagram/src/sequence_resolver.rs +++ b/plantuml/parser/puml_resolver/src/sequence_diagram/src/sequence_resolver.rs @@ -11,38 +11,23 @@ // SPDX-License-Identifier: Apache-2.0 // ******************************************************************************* -use crate::logic_parser::build_tree; use resolver_traits::DiagramResolver; -use sequence_logic::{ - ParticipantType as LogicParticipantType, SequenceParticipant, SequenceTree, SourceLocation, -}; -use sequence_parser::sequence_ast::{ - CreateCmd, MessageEndpoint, ParticipantDef, ParticipantIdentifier, - ParticipantType as SyntaxParticipantType, Statement, -}; +use sequence_logic::SequenceTree; use sequence_parser::SeqPumlDocument; -use std::collections::HashSet; -use std::fmt; + +use crate::error::SequenceResolverError; +use crate::lifecycle_validator::validate_lifecycle_consistency; +use crate::participant_table::build_participant_table; +use crate::statement_resolver::build_sequence_tree; /// Resolver for sequence diagrams. /// -/// Uses the single-pass pattern: `resolve` delegates entirely to `build_tree`, -/// which converts the flat statement list into a `SequenceTree`. The resolver -/// carries no mutable state, so calling `resolve` multiple times is safe. +/// `resolve` builds the participant table and sequence logic tree, then +/// validates lifecycle consistency before assembling the final `SequenceTree`. +/// The resolver stores no per-document state, so the same instance can resolve +/// multiple documents safely. pub struct SequenceResolver; -/// Error type for `SequenceResolver`. -#[derive(Debug)] -pub enum SequenceResolverError {} - -impl fmt::Display for SequenceResolverError { - fn fmt(&self, _formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match *self {} - } -} - -impl std::error::Error for SequenceResolverError {} - impl DiagramResolver for SequenceResolver { type Document = SeqPumlDocument; type Output = SequenceTree; @@ -50,168 +35,25 @@ impl DiagramResolver for SequenceResolver { fn resolve(&mut self, document: &SeqPumlDocument) -> Result { let participants = build_participant_table(&document.statements); - let root_interactions = build_tree(&document.statements); + let root = build_sequence_tree(&document.statements)?; + validate_lifecycle_consistency(&root)?; Ok(SequenceTree { name: document.name.clone(), participants, - root_interactions, + root, }) } } -fn build_participant_table(statements: &[Statement]) -> Vec { - let mut resolved_names = HashSet::new(); - let mut participants = Vec::new(); - - add_explicit_participants(statements, &mut participants, &mut resolved_names); - add_implicit_participants(statements, &mut participants, &mut resolved_names); - - participants -} - -fn add_explicit_participants( - statements: &[Statement], - participants: &mut Vec, - resolved_names: &mut HashSet, -) { - for stmt in statements { - match stmt { - Statement::ParticipantDef(participant_def) => { - add_participant( - participants, - resolved_names, - explicit_participant(participant_def), - ); - } - Statement::CreateCmd(create_cmd) => { - add_participant( - participants, - resolved_names, - created_participant(create_cmd), - ); - } - _ => {} - } - } -} - -fn add_implicit_participants( - statements: &[Statement], - participants: &mut Vec, - resolved_names: &mut HashSet, -) { - for stmt in statements { - if let Statement::Message(msg) = stmt { - add_endpoint_participant( - participants, - resolved_names, - &msg.left, - &msg.source_location, - ); - add_endpoint_participant( - participants, - resolved_names, - &msg.right, - &msg.source_location, - ); - } - } -} - -fn add_participant( - participants: &mut Vec, - resolved_names: &mut HashSet, - participant: SequenceParticipant, -) { - let identifier = ParticipantIdentifier { - display_name: participant.display_name.clone(), - alias: participant.alias.clone(), - }; - let reference_name = participant_reference_name(&identifier); - if reference_name.is_empty() || !resolved_names.insert(reference_name.to_string()) { - return; - } - participants.push(participant); -} - -fn add_endpoint_participant( - participants: &mut Vec, - resolved_names: &mut HashSet, - endpoint: &MessageEndpoint, - source_location: &SourceLocation, -) { - if let MessageEndpoint::Participant(identifier) = endpoint { - add_participant( - participants, - resolved_names, - implicit_participant(identifier, source_location), - ); - } -} - -fn explicit_participant(participant_def: &ParticipantDef) -> SequenceParticipant { - SequenceParticipant { - display_name: participant_def.identifier.display_name.clone(), - alias: participant_def.identifier.alias.clone(), - participant_type: map_parser_participant_type(&participant_def.participant_type), - source_location: participant_def.source_location.clone(), - stereotype: participant_def.stereotype.clone(), - } -} - -fn created_participant(create_cmd: &CreateCmd) -> SequenceParticipant { - SequenceParticipant { - display_name: create_cmd.identifier.display_name.clone(), - alias: create_cmd.identifier.alias.clone(), - participant_type: map_parser_participant_type(&create_cmd.participant_type), - source_location: create_cmd.source_location.clone(), - stereotype: create_cmd.stereotype.clone(), - } -} - -fn implicit_participant( - identifier: &ParticipantIdentifier, - source_location: &SourceLocation, -) -> SequenceParticipant { - SequenceParticipant { - display_name: identifier.display_name.clone(), - alias: identifier.alias.clone(), - participant_type: LogicParticipantType::Participant, - source_location: source_location.clone(), - stereotype: None, - } -} - -fn map_parser_participant_type(kind: &SyntaxParticipantType) -> LogicParticipantType { - match kind { - SyntaxParticipantType::Participant => LogicParticipantType::Participant, - SyntaxParticipantType::Actor => LogicParticipantType::Actor, - SyntaxParticipantType::Boundary => LogicParticipantType::Boundary, - SyntaxParticipantType::Control => LogicParticipantType::Control, - SyntaxParticipantType::Entity => LogicParticipantType::Entity, - SyntaxParticipantType::Queue => LogicParticipantType::Queue, - SyntaxParticipantType::Database => LogicParticipantType::Database, - SyntaxParticipantType::Collections => LogicParticipantType::Collections, - } -} - -fn participant_reference_name(identifier: &ParticipantIdentifier) -> &str { - identifier - .alias - .as_deref() - .unwrap_or(&identifier.display_name) -} - #[cfg(test)] mod sequence_resolver_tests { - use super::*; + use super::SequenceResolver; use parser_core::common_ast::{Arrow, ArrowDecor, ArrowLine}; use resolver_traits::DiagramResolver; use sequence_logic::SourceLocation; use sequence_parser::sequence_ast::{ - Message, MessageEndpoint, ParticipantDef, ParticipantIdentifier, - ParticipantType as SyntaxParticipantType, Statement, + Message, MessageEndpoint, ParticipantIdentifier, SeqPumlDocument, Statement, }; fn solid_arrow() -> Arrow { @@ -227,19 +69,6 @@ mod sequence_resolver_tests { } } - fn dashed_arrow() -> Arrow { - Arrow { - left: None, - line: ArrowLine { - raw: "--".to_string(), - }, - middle: None, - right: Some(ArrowDecor { - raw: ">".to_string(), - }), - } - } - fn dummy_source_location() -> SourceLocation { SourceLocation::new("test.puml", 0) } @@ -262,17 +91,6 @@ mod sequence_resolver_tests { }) } - fn make_return(from: &str, to: &str, label: &str) -> Statement { - Statement::Message(Message { - left: message_endpoint(from), - arrow: dashed_arrow(), - right: message_endpoint(to), - suffix: None, - description: Some(label.to_string()), - source_location: dummy_source_location(), - }) - } - /// SequenceResolver must implement DiagramResolver — compile-time check. #[test] fn test_implements_diagram_resolver_trait() { @@ -289,30 +107,10 @@ mod sequence_resolver_tests { statements: vec![], }; let tree = resolver.resolve(&doc).expect("must not fail"); - assert!(tree.root_interactions.is_empty()); + assert!(tree.root.items.is_empty()); assert_eq!(tree.name.as_deref(), Some("empty")); } - /// A single call with its matching return produces one Interaction node. - #[test] - fn test_call_and_return_produce_one_interaction_node() { - let stmts = vec![ - make_call("A", "B", "doWork"), - make_return("B", "A", "result"), - ]; - let mut resolver = SequenceResolver; - let doc = SeqPumlDocument { - name: Some("test".to_string()), - statements: stmts, - }; - let tree = resolver.resolve(&doc).expect("must not fail"); - assert_eq!( - tree.root_interactions.len(), - 1, - "one call + matching return = one Interaction node at root level" - ); - } - /// resolve must be callable multiple times without carrying state from a previous call. #[test] fn test_resolver_is_stateless_across_calls() { @@ -330,144 +128,6 @@ mod sequence_resolver_tests { let tree1 = resolver.resolve(&doc1).unwrap(); let tree2 = resolver.resolve(&doc2).unwrap(); - assert_eq!(tree1.root_interactions.len(), tree2.root_interactions.len()); - } - - fn make_participant(name: &str) -> Statement { - Statement::ParticipantDef(ParticipantDef { - participant_type: SyntaxParticipantType::Participant, - identifier: ParticipantIdentifier { - display_name: name.to_string(), - alias: None, - }, - stereotype: None, - source_location: dummy_source_location(), - }) - } - - fn make_participant_with_alias(display_name: &str, alias: &str) -> Statement { - Statement::ParticipantDef(ParticipantDef { - participant_type: SyntaxParticipantType::Participant, - identifier: ParticipantIdentifier { - display_name: display_name.to_string(), - alias: Some(alias.to_string()), - }, - stereotype: None, - source_location: dummy_source_location(), - }) - } - - /// Explicit participants remain in the symbol table and message references - /// to them do not create duplicates. - #[test] - fn test_declared_participants_are_preserved_without_duplicates() { - let stmts = vec![ - make_participant("A"), - make_participant("B"), - make_call("A", "B", "doWork"), - make_return("B", "A", "result"), - ]; - let mut resolver = SequenceResolver; - let doc = SeqPumlDocument { - name: Some("valid".to_string()), - statements: stmts, - }; - let tree = resolver.resolve(&doc).expect("must not fail"); - assert_eq!(tree.participants.len(), 2); - assert_eq!(tree.participants[0].display_name, "A"); - assert_eq!(tree.participants[1].display_name, "B"); - } - - #[test] - fn test_aliased_participant_reference_does_not_create_duplicate() { - let stmts = vec![ - make_participant("A"), - make_participant_with_alias("Display B", "B"), - make_call("A", "B", "doWork"), - ]; - let mut resolver = SequenceResolver; - let doc = SeqPumlDocument { - name: Some("valid_alias".to_string()), - statements: stmts, - }; - let tree = resolver.resolve(&doc).expect("must not fail"); - assert_eq!(tree.participants.len(), 2); - assert_eq!(tree.participants[1].display_name, "Display B"); - assert_eq!(tree.participants[1].alias.as_deref(), Some("B")); - } - - #[test] - fn test_aliased_participant_display_name_reference_creates_implicit_participant() { - let stmts = vec![ - make_participant("A"), - make_participant_with_alias("Display B", "B"), - make_call("A", "Display B", "doWork"), - ]; - let mut resolver = SequenceResolver; - let doc = SeqPumlDocument { - name: Some("invalid_display_reference".to_string()), - statements: stmts, - }; - let tree = resolver.resolve(&doc).expect("must not fail"); - assert_eq!(tree.participants.len(), 3); - assert_eq!(tree.participants[2].display_name, "Display B"); - assert_eq!(tree.participants[2].alias, None); - } - - /// When no participants are declared, message endpoints form the participant table. - #[test] - fn test_no_participants_declared_creates_implicit_participants() { - let stmts = vec![make_call("X", "Y", "hello")]; - let mut resolver = SequenceResolver; - let doc = SeqPumlDocument { - name: Some("implicit".to_string()), - statements: stmts, - }; - let tree = resolver.resolve(&doc).expect("must not fail"); - assert_eq!(tree.participants.len(), 2); - assert_eq!(tree.participants[0].display_name, "X"); - assert_eq!(tree.participants[1].display_name, "Y"); - } - - /// Resolver output nodes must preserve source_location provenance. - #[test] - fn test_source_locations_are_preserved() { - let call_location = SourceLocation::new("sequence/provenance_case.puml", 42); - let return_location = SourceLocation::new("sequence/provenance_case.puml", 43); - - let stmts = vec![ - Statement::Message(Message { - left: message_endpoint("A"), - arrow: solid_arrow(), - right: message_endpoint("B"), - suffix: None, - description: Some("doWork".to_string()), - source_location: call_location.clone(), - }), - Statement::Message(Message { - left: message_endpoint("B"), - arrow: dashed_arrow(), - right: message_endpoint("A"), - suffix: None, - description: Some("result".to_string()), - source_location: return_location.clone(), - }), - ]; - - let mut resolver = SequenceResolver; - let doc = SeqPumlDocument { - name: Some("provenance".to_string()), - statements: stmts, - }; - - let tree = resolver.resolve(&doc).expect("must not fail"); - assert_eq!(tree.root_interactions.len(), 1); - - let interaction = &tree.root_interactions[0]; - assert_eq!(interaction.source_location, call_location); - - assert_eq!(interaction.branches_node.len(), 1); - let ret = &interaction.branches_node[0]; - assert_eq!(ret.source_location, return_location); + assert_eq!(tree1.root.items.len(), tree2.root.items.len()); } } diff --git a/plantuml/parser/puml_resolver/src/sequence_diagram/src/sequence_tree_builder.rs b/plantuml/parser/puml_resolver/src/sequence_diagram/src/sequence_tree_builder.rs new file mode 100644 index 00000000..b9e1c269 --- /dev/null +++ b/plantuml/parser/puml_resolver/src/sequence_diagram/src/sequence_tree_builder.rs @@ -0,0 +1,219 @@ +// ******************************************************************************* +// Copyright (c) 2026 Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// This program and the accompanying materials are made available under the +// terms of the Apache License Version 2.0 which is available at +// +// +// SPDX-License-Identifier: Apache-2.0 +// ******************************************************************************* + +use sequence_logic::{ + Block, Branch, BranchCase, EarlyExit, Loop, Node, Parallel, ParallelBranch, SourceLocation, +}; +use sequence_parser::{GroupElse, GroupEnd, GroupKind, GroupStart}; + +use crate::error::SequenceResolverError; + +struct GroupContext { + kind: GroupKind, + source_location: SourceLocation, + mode: GroupMode, +} + +enum GroupMode { + Loop(Loop), + Break(EarlyExit), + Branch { + node: Branch, + active_case: usize, + }, + Parallel { + node: Parallel, + active_branch: usize, + }, + Transparent { + block: Block, + }, // group + Ignored, // critical +} + +pub(crate) struct SequenceTreeBuilder { + root: Block, + group_stack: Vec, +} + +impl SequenceTreeBuilder { + pub(crate) fn new() -> Self { + Self { + root: Block::default(), + group_stack: Vec::new(), + } + } + + pub(crate) fn finish(self) -> Result { + if let Some(context) = self.group_stack.last() { + return Err(SequenceResolverError::UnterminatedGroup { + source_location: context.source_location.clone(), + }); + } + + Ok(self.root) + } + + pub(crate) fn push(&mut self, node: Node) { + let Some(context) = self.group_stack.last_mut() else { + self.root.items.push(node); + return; + }; + + match &mut context.mode { + GroupMode::Loop(loop_node) => loop_node.block.items.push(node), + GroupMode::Break(exit) => exit.block.items.push(node), + GroupMode::Branch { + node: branch, + active_case, + } => branch.cases[*active_case].block.items.push(node), + GroupMode::Parallel { + node: parallel, + active_branch, + } => parallel.branches[*active_branch].block.items.push(node), + GroupMode::Transparent { block } => block.items.push(node), + GroupMode::Ignored => {} + } + } + + pub(crate) fn start_group(&mut self, start: &GroupStart) { + let mode = match start.kind { + GroupKind::Loop => GroupMode::Loop(Loop { + condition: start.label.clone(), + block: Block::default(), + source_location: start.source_location.clone(), + }), + GroupKind::Break => GroupMode::Break(EarlyExit { + reason: start.label.clone(), + block: Block::default(), + source_location: start.source_location.clone(), + }), + GroupKind::Alt | GroupKind::Opt => GroupMode::Branch { + node: Branch { + cases: vec![BranchCase { + condition: start.label.clone(), + block: Block::default(), + source_location: start.source_location.clone(), + }], + }, + active_case: 0, + }, + GroupKind::Par => GroupMode::Parallel { + node: Parallel { + branches: vec![ParallelBranch { + label: start.label.clone(), + block: Block::default(), + source_location: start.source_location.clone(), + }], + }, + active_branch: 0, + }, + // A visual group still needs a frame so its matching `end` is + // consumed and nested groups remain structurally balanced. + GroupKind::Group => GroupMode::Transparent { + block: Block::default(), + }, + // Critical is parsed and validated, but its contents are not + // represented in the current logic model. + GroupKind::Critical => GroupMode::Ignored, + }; + + self.group_stack.push(GroupContext { + kind: start.kind, + source_location: start.source_location.clone(), + mode, + }); + } + + pub(crate) fn else_group(&mut self, command: &GroupElse) -> Result<(), SequenceResolverError> { + let Some(context) = self.group_stack.last_mut() else { + // Although PlantUML preview rejects a top-level else, the resolver still rejects it explicitly rather than silently ignoring it — as a safeguard. + return Err(SequenceResolverError::ElseOutsideGroup { + source_location: command.source_location.clone(), + }); + }; + + match &mut context.mode { + GroupMode::Branch { + node: branch, + active_case, + } => { + if context.kind != GroupKind::Alt { + return Err(SequenceResolverError::ElseNotAllowedInGroup { + kind: context.kind, + source_location: command.source_location.clone(), + }); + } + branch.cases.push(BranchCase { + condition: command.label.clone(), + block: Block::default(), + source_location: command.source_location.clone(), + }); + *active_case = branch.cases.len() - 1; + } + GroupMode::Parallel { + node: parallel, + active_branch, + } => { + parallel.branches.push(ParallelBranch { + label: command.label.clone(), + block: Block::default(), + source_location: command.source_location.clone(), + }); + *active_branch = parallel.branches.len() - 1; + } + GroupMode::Loop(_) + | GroupMode::Break(_) + | GroupMode::Transparent { .. } + | GroupMode::Ignored => { + return Err(SequenceResolverError::ElseNotAllowedInGroup { + kind: context.kind, + source_location: command.source_location.clone(), + }); + } + } + + Ok(()) + } + + pub(crate) fn end_group(&mut self, end: &GroupEnd) -> Result<(), SequenceResolverError> { + let Some(context) = self.group_stack.pop() else { + return Ok(()); + }; + + if let Some(found) = end.kind { + if found != context.kind { + return Err(SequenceResolverError::MismatchedGroupEnd { + expected: context.kind, + found, + source_location: end.source_location.clone(), + }); + } + } + + match context.mode { + GroupMode::Loop(loop_node) => self.push(Node::Loop(loop_node)), + GroupMode::Break(exit) => self.push(Node::EarlyExit(exit)), + GroupMode::Branch { node: branch, .. } => self.push(Node::Branch(branch)), + GroupMode::Parallel { node: parallel, .. } => self.push(Node::Parallel(parallel)), + GroupMode::Transparent { block } => { + for node in block.items { + self.push(node); + } + } + GroupMode::Ignored => {} + } + + Ok(()) + } +} diff --git a/plantuml/parser/puml_resolver/src/sequence_diagram/src/statement_resolver.rs b/plantuml/parser/puml_resolver/src/sequence_diagram/src/statement_resolver.rs new file mode 100644 index 00000000..998e16da --- /dev/null +++ b/plantuml/parser/puml_resolver/src/sequence_diagram/src/statement_resolver.rs @@ -0,0 +1,468 @@ +// ******************************************************************************* +// Copyright (c) 2026 Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// This program and the accompanying materials are made available under the +// terms of the Apache License Version 2.0 which is available at +// +// +// SPDX-License-Identifier: Apache-2.0 +// ******************************************************************************* + +use std::sync::Arc; + +use parser_core::format_arrow; +use sequence_logic::{ + Block, Interaction, LifecycleAction, Node, ParticipantId, ParticipantLifecycle, Reference, + SourceLocation, +}; +use sequence_parser::{ + GroupCmd, Message, MessageEndpoint, MessageSuffix, ParticipantRef, RefCmd, Statement, +}; + +use crate::error::SequenceResolverError; +use crate::participant_table::participant_reference_name; +use crate::sequence_tree_builder::SequenceTreeBuilder; + +#[derive(Debug, Clone, Copy)] +struct LifecycleOp { + action: LifecycleAction, + target: LifecycleTarget, +} + +#[derive(Debug, Clone, Copy)] +enum LifecycleTarget { + Sender, + Receiver, +} + +pub(crate) fn build_sequence_tree( + statements: &[Statement], +) -> Result { + let mut builder = SequenceTreeBuilder::new(); + + for statement in statements { + consume_statement(&mut builder, statement)?; + } + + builder.finish() +} + +fn consume_statement( + builder: &mut SequenceTreeBuilder, + statement: &Statement, +) -> Result<(), SequenceResolverError> { + match statement { + Statement::Message(message) => { + for node in message_nodes(message)? { + builder.push(node); + } + } + Statement::GroupCmd(GroupCmd::Start(start)) => builder.start_group(start), + Statement::GroupCmd(GroupCmd::Else(cmd)) => builder.else_group(cmd)?, + Statement::GroupCmd(GroupCmd::End(end)) => builder.end_group(end)?, + Statement::CreateCmd(create_cmd) => { + builder.push(lifecycle_node( + Arc::::from(participant_reference_name(&create_cmd.identifier)), + LifecycleAction::Create, + create_cmd.source_location.clone(), + )); + } + Statement::DestroyCmd(destroy_cmd) => { + builder.push(lifecycle_node( + participant_ref_name(&destroy_cmd.participant), + LifecycleAction::Destroy, + destroy_cmd.source_location.clone(), + )); + } + Statement::ActivateCmd(activate_cmd) => { + builder.push(lifecycle_node( + participant_ref_name(&activate_cmd.participant), + LifecycleAction::Activate, + activate_cmd.source_location.clone(), + )); + } + Statement::DeactivateCmd(deactivate_cmd) => { + builder.push(lifecycle_node( + participant_ref_name(&deactivate_cmd.participant), + LifecycleAction::Deactivate, + deactivate_cmd.source_location.clone(), + )); + } + Statement::RefCmd(ref_cmd) => builder.push(reference_node(ref_cmd)), + Statement::ParticipantDef(_) => {} + // `return` has no sender or receiver. Modeling it requires a call stack + // to resolve the matching invocation, which the resolver does not yet maintain. + Statement::ReturnCmd(_) => {} + } + + Ok(()) +} + +fn message_nodes(message: &Message) -> Result, SequenceResolverError> { + let (sender, receiver) = directed_endpoints(message)?; + + let sender_name = endpoint_name(sender); + let receiver_name = endpoint_name(receiver); + + let lifecycle_ops = collect_message_lifecycle_ops(message.suffix.as_ref()); + + let mut nodes = Vec::new(); + + // Create lifecycle happens before message delivery. + for op in &lifecycle_ops { + if op.action != LifecycleAction::Create { + continue; + } + + if let Some(participant) = receiver_name.clone() { + nodes.push(lifecycle_node( + participant, + op.action, + message.source_location.clone(), + )); + } + } + + nodes.push(Node::Interaction(Interaction { + sender: sender_name.clone(), + receiver: receiver_name.clone(), + message: message.description.clone(), + source_location: message.source_location.clone(), + })); + + // Other lifecycle actions happen after the interaction. + for op in lifecycle_ops { + if op.action == LifecycleAction::Create { + continue; + } + + let participant = match op.target { + LifecycleTarget::Sender => &sender_name, + LifecycleTarget::Receiver => &receiver_name, + }; + + if let Some(participant) = participant.clone() { + nodes.push(lifecycle_node( + participant, + op.action, + message.source_location.clone(), + )); + } + } + + Ok(nodes) +} + +fn collect_message_lifecycle_ops(suffix: Option<&MessageSuffix>) -> Vec { + let mut ops = Vec::new(); + + if let Some(suffix) = suffix { + collect_lifecycle_ops(suffix, &mut ops); + } + + ops +} + +fn collect_lifecycle_ops(suffix: &MessageSuffix, output: &mut Vec) { + match suffix { + MessageSuffix::Combined(items) => { + for item in items { + collect_lifecycle_ops(item, output); + } + } + suffix => { + output.push(suffix_to_lifecycle(suffix)); + } + } +} + +fn suffix_to_lifecycle(suffix: &MessageSuffix) -> LifecycleOp { + match suffix { + MessageSuffix::Activate => LifecycleOp { + action: LifecycleAction::Activate, + target: LifecycleTarget::Receiver, + }, + + MessageSuffix::Deactivate => LifecycleOp { + action: LifecycleAction::Deactivate, + target: LifecycleTarget::Sender, + }, + + MessageSuffix::Create => LifecycleOp { + action: LifecycleAction::Create, + target: LifecycleTarget::Receiver, + }, + + MessageSuffix::Destroy => LifecycleOp { + action: LifecycleAction::Destroy, + target: LifecycleTarget::Receiver, + }, + + MessageSuffix::Combined(_) => { + unreachable!("combined suffix must be flattened first") + } + } +} + +fn reference_node(ref_cmd: &RefCmd) -> Node { + Node::Reference(Reference { + participants: ref_cmd + .participants + .iter() + .map(participant_ref_name) + .collect(), + text: ref_cmd.text.clone(), + source_location: ref_cmd.source_location.clone(), + }) +} + +fn lifecycle_node( + participant: ParticipantId, + action: LifecycleAction, + source_location: SourceLocation, +) -> Node { + Node::Lifecycle(ParticipantLifecycle { + participant, + action, + source_location, + }) +} + +fn endpoint_name(endpoint: &MessageEndpoint) -> Option { + match endpoint { + MessageEndpoint::Participant(identifier) => { + Some(Arc::::from(participant_reference_name(identifier))) + } + MessageEndpoint::LostFound(_) => None, + } +} + +fn participant_ref_name(participant: &ParticipantRef) -> ParticipantId { + Arc::::from(participant.identifier.as_str()) +} + +fn directed_endpoints( + message: &Message, +) -> Result<(&MessageEndpoint, &MessageEndpoint), SequenceResolverError> { + let arrow = &message.arrow; + + let left_arrow = arrow.left.as_ref().is_some_and(|d| d.raw.contains('<')); + + let right_arrow = arrow.right.as_ref().is_some_and(|d| d.raw.contains('>')); + + match (left_arrow, right_arrow) { + (true, false) => Ok((&message.right, &message.left)), + (false, true) => Ok((&message.left, &message.right)), + _ => Err(SequenceResolverError::InvalidMessageDirection { + arrow: format_arrow(arrow), + source_location: message.source_location.clone(), + }), + } +} + +#[cfg(test)] +mod message_arrow_tests { + use super::*; + use parser_core::common_ast::{Arrow, ArrowDecor, ArrowLine}; + use sequence_parser::ParticipantIdentifier; + + fn arrow(line: &str, right: Option<&str>) -> Arrow { + Arrow { + left: None, + line: ArrowLine { + raw: line.to_string(), + }, + middle: None, + right: right.map(|r| ArrowDecor { raw: r.to_string() }), + } + } + + fn bidirectional_arrow() -> Arrow { + Arrow { + left: Some(ArrowDecor { + raw: "<".to_string(), + }), + line: ArrowLine { + raw: "--".to_string(), + }, + middle: None, + right: Some(ArrowDecor { + raw: ">".to_string(), + }), + } + } + + fn message(arrow: Arrow) -> Message { + Message { + left: MessageEndpoint::Participant(ParticipantIdentifier { + display_name: "A".to_string(), + alias: None, + }), + arrow, + right: MessageEndpoint::Participant(ParticipantIdentifier { + display_name: "B".to_string(), + alias: None, + }), + suffix: None, + description: None, + source_location: SourceLocation::new("", 0), + } + } + + #[test] + fn test_solid_directed_arrow_produces_interaction() { + assert_eq!( + message_nodes(&message(arrow("-", Some(">")))) + .expect("must resolve a directed arrow") + .len(), + 1 + ); + } + + #[test] + fn test_dashed_directed_arrow_produces_interaction() { + assert_eq!( + message_nodes(&message(arrow("--", Some(">")))) + .expect("must resolve a directed arrow") + .len(), + 1 + ); + } + + #[test] + fn test_bidirectional_arrow_is_rejected() { + let err = directed_endpoints(&message(bidirectional_arrow())) + .expect_err("must reject bidirectional arrows"); + + assert!(matches!( + err, + SequenceResolverError::InvalidMessageDirection { arrow, .. } + if arrow == "<-->" + )); + } + + #[test] + fn test_undirected_arrow_is_rejected() { + let err = directed_endpoints(&message(arrow("--", None))) + .expect_err("must reject undirected arrows"); + + assert!(matches!( + err, + SequenceResolverError::InvalidMessageDirection { arrow, .. } + if arrow == "--" + )); + } + + #[test] + fn test_lifecycle_suffixes_target_the_correct_message_endpoint() { + let cases = [ + (MessageSuffix::Activate, "B", LifecycleAction::Activate), + (MessageSuffix::Deactivate, "A", LifecycleAction::Deactivate), + (MessageSuffix::Create, "B", LifecycleAction::Create), + (MessageSuffix::Destroy, "B", LifecycleAction::Destroy), + ]; + + for (suffix, participant, action) in cases { + let is_create = matches!(suffix, MessageSuffix::Create); + let mut suffixed_message = message(arrow("-", Some(">"))); + suffixed_message.suffix = Some(suffix); + + let nodes = message_nodes(&suffixed_message).expect("suffix must resolve"); + let lifecycle_matches = |lifecycle: &ParticipantLifecycle| { + lifecycle.participant.as_ref() == participant && lifecycle.action == action + }; + + if is_create { + assert!(matches!( + nodes.as_slice(), + [ + Node::Lifecycle(lifecycle), + Node::Interaction(_), + ] if lifecycle_matches(lifecycle) + )); + } else { + assert!(matches!( + nodes.as_slice(), + [ + Node::Interaction(_), + Node::Lifecycle(lifecycle), + ] if lifecycle_matches(lifecycle) + )); + } + } + } + + #[test] + fn test_message_nodes_preserve_source_locations() { + let call_location = SourceLocation::new("sequence/provenance_case.puml", 42); + let return_location = SourceLocation::new("sequence/provenance_case.puml", 43); + let mut call = message(arrow("-", Some(">"))); + call.source_location = call_location.clone(); + let mut return_message = message(arrow("--", Some(">"))); + return_message.source_location = return_location.clone(); + + let root = + build_sequence_tree(&[Statement::Message(call), Statement::Message(return_message)]) + .expect("messages must resolve"); + + assert_eq!(root.items.len(), 2); + let Node::Interaction(interaction) = &root.items[0] else { + panic!("expected interaction node"); + }; + assert_eq!(interaction.source_location, call_location); + + let Node::Interaction(interaction) = &root.items[1] else { + panic!("expected interaction node"); + }; + assert_eq!(interaction.source_location, return_location); + } + + #[test] + fn test_combined_lifecycle_suffix_resolves_in_source_order() { + let mut combined_message = message(arrow("-", Some(">"))); + combined_message.suffix = Some(MessageSuffix::Combined(vec![ + MessageSuffix::Deactivate, + MessageSuffix::Activate, + ])); + + let nodes = message_nodes(&combined_message).expect("combined suffix must resolve"); + assert!(matches!( + nodes.as_slice(), + [ + Node::Interaction(interaction), + Node::Lifecycle(deactivate), + Node::Lifecycle(activate), + ] if interaction.sender.as_deref() == Some("A") + && interaction.receiver.as_deref() == Some("B") + && deactivate.participant.as_ref() == "A" + && deactivate.action == LifecycleAction::Deactivate + && activate.participant.as_ref() == "B" + && activate.action == LifecycleAction::Activate + )); + } + + #[test] + fn test_combined_create_and_activate_suffix_creates_before_interaction() { + let mut combined_message = message(arrow("-", Some(">"))); + combined_message.suffix = Some(MessageSuffix::Combined(vec![ + MessageSuffix::Create, + MessageSuffix::Activate, + ])); + + let nodes = message_nodes(&combined_message).expect("combined suffix must resolve"); + assert!(matches!( + nodes.as_slice(), + [ + Node::Lifecycle(create), + Node::Interaction(_), + Node::Lifecycle(activate), + ] if create.participant.as_ref() == "B" + && create.action == LifecycleAction::Create + && activate.participant.as_ref() == "B" + && activate.action == LifecycleAction::Activate + )); + } +} diff --git a/plantuml/parser/puml_resolver/src/sequence_diagram/tests/sequence_resolver_test.rs b/plantuml/parser/puml_resolver/src/sequence_diagram/tests/sequence_resolver_test.rs index 98539621..4ac06f2f 100644 --- a/plantuml/parser/puml_resolver/src/sequence_diagram/tests/sequence_resolver_test.rs +++ b/plantuml/parser/puml_resolver/src/sequence_diagram/tests/sequence_resolver_test.rs @@ -80,3 +80,83 @@ fn test_lost_found_endpoint_resolution() { fn test_implicit_participant_from_message_endpoint() { run_sequence_resolver_case("implicit_participant_from_message_endpoint"); } + +#[test] +fn test_sequence_interaction_node() { + run_sequence_resolver_case("sequence_interaction_node"); +} + +#[test] +fn test_sequence_arrow_direction() { + run_sequence_resolver_case("sequence_arrow_direction"); +} + +#[test] +fn test_sequence_reference_node() { + run_sequence_resolver_case("sequence_reference_node"); +} + +#[test] +fn test_sequence_lifecycle_nodes() { + run_sequence_resolver_case("sequence_lifecycle_nodes"); +} + +#[test] +fn test_sequence_group_container() { + run_sequence_resolver_case("sequence_group_container"); +} + +#[test] +fn test_combined_lifecycle_suffix() { + run_sequence_resolver_case("combined_lifecycle_suffix"); +} + +#[test] +fn test_invalid_message_direction() { + run_sequence_resolver_case("invalid_message_direction"); +} + +#[test] +fn test_invalid_unterminated_group() { + run_sequence_resolver_case("invalid_unterminated_group"); +} + +#[test] +fn test_invalid_else_in_opt() { + run_sequence_resolver_case("invalid_else_in_opt"); +} + +#[test] +fn test_invalid_mismatched_group_end() { + run_sequence_resolver_case("invalid_mismatched_group_end"); +} + +#[test] +fn test_sequence_branch_node() { + run_sequence_resolver_case("sequence_branch_node"); +} + +#[test] +fn test_sequence_loop_node() { + run_sequence_resolver_case("sequence_loop_node"); +} + +#[test] +fn test_sequence_parallel_node() { + run_sequence_resolver_case("sequence_parallel_node"); +} + +#[test] +fn test_sequence_early_exit_node() { + run_sequence_resolver_case("sequence_early_exit_node"); +} + +#[test] +fn test_invalid_destroyed_participant_use_is_rejected() { + run_sequence_resolver_case("invalid_destroyed_participant_use"); +} + +#[test] +fn test_recreate_destroyed_participant_is_allowed() { + run_sequence_resolver_case("recreate_destroyed_participant"); +} diff --git a/tools/metamodel/sequence/sequence_logic.rs b/tools/metamodel/sequence/sequence_logic.rs index 211d4fa4..4697c14b 100644 --- a/tools/metamodel/sequence/sequence_logic.rs +++ b/tools/metamodel/sequence/sequence_logic.rs @@ -13,6 +13,7 @@ use serde::{Deserialize, Serialize}; pub use source_location::SourceLocation; +use std::sync::Arc; /// A single item inside a function/branch/loop body, emitted in execution order. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -40,61 +41,135 @@ pub struct FunctionDef { pub body: Vec, } -// ─── PlantUML sequence-diagram logic-tree types ───────────────────────────── +/// For a PlantUML sequence diagram, this is the resolved participant identifier +/// (typically the alias if present, otherwise the display name). +/// +/// For C++ code, this is typically the object/class identifier resolved from +/// the call site. +pub type ParticipantId = Arc; -/// The kind of condition / group block in a sequence diagram. +/// A reference fragment. +/// +/// PlantUML: ref over A,B : Authentication +/// C++: Optional for the first version #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum ConditionType { - Opt, - Alt, - Loop, - Par, - Par2, - Break, - Critical, - Else, - Also, - End, - Group, -} - -/// A condition / group block header in a sequence diagram. +pub struct Reference { + pub participants: Vec, + pub text: Option, + pub source_location: SourceLocation, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum LifecycleAction { + Create, + Activate, + Deactivate, + Destroy, +} + +/// Participant lifecycle. +/// +/// create: auto foo = std::make_shared(); +/// activate: +/// deactivate +/// destroy: delete foo; +/// C++: Optional for the first version #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct Condition { - pub condition_type: ConditionType, - pub condition_value: String, +pub struct ParticipantLifecycle { + pub participant: ParticipantId, + pub action: LifecycleAction, + pub source_location: SourceLocation, } -/// A method-call interaction between two participants. +/// Early exit from the current interaction. +/// +/// PlantUML: break +/// C++: return #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct Interaction { - pub caller: String, - pub callee: String, - pub method: String, +pub struct EarlyExit { + pub reason: Option, + pub block: Block, + pub source_location: SourceLocation, } -/// A return message between two participants. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct Return { - pub caller: String, - pub callee: String, - pub return_content: String, +pub struct ParallelBranch { + pub label: Option, + pub block: Block, + pub source_location: SourceLocation, } -/// An event in a sequence diagram: a call, a return, or a condition block. +/// Parallel execution. +/// +/// PlantUML: par-else +/// C++: std::thread / std::async / co_await +/// +/// Note: First version can ignore the C++ mapping, as it is not a direct equivalent #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum Event { - Interaction(Interaction), - Return(Return), - Condition(Condition), +pub struct Parallel { + pub branches: Vec, +} + +/// Loop execution. +/// +/// PlantUML: loop ... +/// C++: while (...) / do-while (...) / for (...) +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Loop { + pub condition: Option, + pub block: Block, + pub source_location: SourceLocation, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct BranchCase { + pub condition: Option, + pub block: Block, + pub source_location: SourceLocation, +} + +/// Conditional execution. +/// +/// PlantUML: alt-else, opt +/// C++: `if` / `else if` / `else` +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Branch { + pub cases: Vec, } -/// A node in the hierarchical sequence-diagram logic tree. +/// A message between two participants. +/// +/// PlantUML: +/// 1) A -> B : foo() +/// 2) return xxx +/// C++: +/// class A { +/// void func(B& b) { b.foo(); } +/// } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct SequenceNode { - pub event: Event, +pub struct Interaction { + /// None represents a PlantUML lost/found endpoint. + pub sender: Option, + /// None represents a PlantUML lost/found endpoint. + pub receiver: Option, + pub message: Option, pub source_location: SourceLocation, - pub branches_node: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum Node { + Interaction(Interaction), + Branch(Branch), + Loop(Loop), + Parallel(Parallel), + EarlyExit(EarlyExit), + Lifecycle(ParticipantLifecycle), + Reference(Reference), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +pub struct Block { + pub items: Vec, } /// A participant in a sequence diagram. @@ -121,11 +196,29 @@ pub struct SequenceParticipant { pub stereotype: Option, } -/// Root container for a sequence-diagram logic tree. +/// Root of a resolved sequence behavior tree. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct SequenceTree { pub name: Option, #[serde(default)] pub participants: Vec, - pub root_interactions: Vec, + pub root: Block, +} + +impl SequenceTree { + /// Return owned names used to reference this tree's participants. + /// + /// PlantUML references a participant by its alias when one exists; + /// otherwise it uses the participant's display name. Callers choose their + /// own collection type so they can preserve the ordering and deduplication + /// semantics needed by their use case. + pub fn participant_reference_names(&self) -> impl Iterator + '_ { + self.participants.iter().map(|participant| { + participant + .alias + .as_deref() + .unwrap_or(&participant.display_name) + .to_string() + }) + } } diff --git a/tools/serialization/flatbuffers/sequence/sequence_diagram.fbs b/tools/serialization/flatbuffers/sequence/sequence_diagram.fbs index b3f45680..dfabca00 100644 --- a/tools/serialization/flatbuffers/sequence/sequence_diagram.fbs +++ b/tools/serialization/flatbuffers/sequence/sequence_diagram.fbs @@ -18,29 +18,6 @@ table SourceLocation { line: uint32; } -/// The kind of condition / group block in a sequence diagram. -enum ConditionType : byte { - Opt = 0, - Alt = 1, - Loop = 2, - Par = 3, - Par2 = 4, - Break = 5, - Critical = 6, - Else = 7, - Also = 8, - End = 9, - Group = 10 -} - -/// The discriminator for the event union. -enum EventType : byte { - NONE = 0, - Interaction = 1, - Return = 2, - Condition = 3 -} - /// The kind of participant declared in a sequence diagram. enum ParticipantType : byte { Participant = 0, @@ -53,32 +30,23 @@ enum ParticipantType : byte { Collections = 7 } -/// A method-call interaction between two participants. +/// A message interaction between two participants. table Interaction { /// Sending participant - caller: string (required); + sender: string; /// Receiving participant - callee: string (required); + receiver: string; /// Method or message label - method: string; -} - -/// A return message between two participants. -table Return { - /// Original caller (receiver of the return) - caller: string (required); - /// Original callee (sender of the return) - callee: string (required); - /// Return value / label - return_content: string; + message: string; + /// Source location for this interaction + source_location: SourceLocation (required); } -/// A condition / group block header. -table Condition { - /// Kind of block (alt, loop, opt, …) - condition_type: ConditionType = Alt; - /// Guard expression or label text - condition_value: string; +enum LifecycleAction : byte { + Create = 0, + Activate = 1, + Deactivate = 2, + Destroy = 3 } /// A declared participant in a sequence diagram. @@ -95,31 +63,77 @@ table SequenceParticipant { stereotype: string; } -/// Union of possible events carried by a sequence node. -union Event { - Interaction, - Return, - Condition +table ParticipantLifecycle { + participant: string (required); + action: LifecycleAction = Create; + source_location: SourceLocation (required); +} + +table Reference { + participants: [string]; + text: string; + source_location: SourceLocation (required); +} + +table BranchCase { + condition: string; + block: Block (required); + source_location: SourceLocation (required); +} + +table Branch { + cases: [BranchCase]; } -/// A node in the hierarchical sequence-diagram logic tree. -table SequenceNode { - /// The event at this node - event: Event; - /// Source location for this node +table Loop { + condition: string; + block: Block (required); source_location: SourceLocation (required); - /// Child nodes (nested calls, branches, …) - branches_node: [SequenceNode]; +} + +table ParallelBranch { + label: string; + block: Block (required); + source_location: SourceLocation (required); +} + +table Parallel { + branches: [ParallelBranch]; +} + +table EarlyExit { + reason: string; + block: Block (required); + source_location: SourceLocation (required); +} + +/// Union of possible sequence logic nodes. +union Node { + Interaction, + Branch, + Loop, + Parallel, + EarlyExit, + ParticipantLifecycle, + Reference +} + +table NodeItem { + node: Node; +} + +table Block { + items: [NodeItem]; } /// Root container for a sequence-diagram logic tree. table SequenceDiagram { /// Diagram name (usually the source file) name: string; - /// Top-level sequence nodes - root_interactions: [SequenceNode]; /// Declared participants in document order participants: [SequenceParticipant]; + /// Root behavior block + root: Block (required); } root_type SequenceDiagram; diff --git a/tools/serialization/flatbuffers/sequence/sequence_serializer.rs b/tools/serialization/flatbuffers/sequence/sequence_serializer.rs index 3ffdd718..ea6e5b42 100644 --- a/tools/serialization/flatbuffers/sequence/sequence_serializer.rs +++ b/tools/serialization/flatbuffers/sequence/sequence_serializer.rs @@ -14,7 +14,11 @@ use flatbuffers::FlatBufferBuilder; use sequence_fbs::sequence_metamodel as fb; use sequence_logic::{ - ConditionType, Event, ParticipantType, SequenceNode, SequenceParticipant, SequenceTree, + Block as LogicBlock, Branch as LogicBranch, BranchCase as LogicBranchCase, + EarlyExit as LogicEarlyExit, Interaction as LogicInteraction, LifecycleAction, + Loop as LogicLoop, Node as LogicNode, Parallel as LogicParallel, + ParallelBranch as LogicParallelBranch, ParticipantLifecycle, ParticipantType, Reference, + SequenceParticipant, SequenceTree, SourceLocation, }; pub struct SequenceSerializer; @@ -45,19 +49,14 @@ impl SequenceSerializer { .collect(); let participants_offset = builder.create_vector(&participant_offsets); - let node_offsets: Vec<_> = diagram - .root_interactions - .iter() - .map(|node| Self::serialize_node(&mut builder, node)) - .collect(); - let nodes_offset = builder.create_vector(&node_offsets); + let root_offset = Self::serialize_block(&mut builder, &diagram.root); let root = fb::SequenceDiagram::create( &mut builder, &fb::SequenceDiagramArgs { name: name_offset, participants: Some(participants_offset), - root_interactions: Some(nodes_offset), + root: Some(root_offset), }, ); @@ -99,103 +98,273 @@ impl SequenceSerializer { ) } - fn serialize_node<'a>( + fn serialize_block<'a>( builder: &mut FlatBufferBuilder<'a>, - node: &SequenceNode, - ) -> flatbuffers::WIPOffset> { - // Recursively serialize child nodes first (depth-first). - let branch_offsets: Vec<_> = node - .branches_node + block: &LogicBlock, + ) -> flatbuffers::WIPOffset> { + let item_offsets: Vec<_> = block + .items .iter() - .map(|child| Self::serialize_node(builder, child)) + .map(|node| Self::serialize_node_item(builder, node)) .collect(); - let branches_offset = builder.create_vector(&branch_offsets); - let location_file_offset = builder.create_string(node.source_location.file.as_ref()); - let source_location = fb::SourceLocation::create( + let items = builder.create_vector(&item_offsets); + + fb::Block::create(builder, &fb::BlockArgs { items: Some(items) }) + } + + fn serialize_node_item<'a>( + builder: &mut FlatBufferBuilder<'a>, + node: &LogicNode, + ) -> flatbuffers::WIPOffset> { + let (node_type, node) = match node { + LogicNode::Interaction(interaction) => ( + fb::Node::Interaction, + Self::serialize_interaction(builder, interaction).as_union_value(), + ), + LogicNode::Branch(branch) => ( + fb::Node::Branch, + Self::serialize_branch(builder, branch).as_union_value(), + ), + LogicNode::Loop(loop_node) => ( + fb::Node::Loop, + Self::serialize_loop(builder, loop_node).as_union_value(), + ), + LogicNode::Parallel(parallel) => ( + fb::Node::Parallel, + Self::serialize_parallel(builder, parallel).as_union_value(), + ), + LogicNode::EarlyExit(early_exit) => ( + fb::Node::EarlyExit, + Self::serialize_early_exit(builder, early_exit).as_union_value(), + ), + LogicNode::Lifecycle(lifecycle) => ( + fb::Node::ParticipantLifecycle, + Self::serialize_lifecycle(builder, lifecycle).as_union_value(), + ), + LogicNode::Reference(reference) => ( + fb::Node::Reference, + Self::serialize_reference(builder, reference).as_union_value(), + ), + }; + + fb::NodeItem::create( builder, - &fb::SourceLocationArgs { - file: Some(location_file_offset), - line: node.source_location.line, + &fb::NodeItemArgs { + node_type, + node: Some(node), }, - ); + ) + } + + fn serialize_interaction<'a>( + builder: &mut FlatBufferBuilder<'a>, + interaction: &LogicInteraction, + ) -> flatbuffers::WIPOffset> { + let sender = interaction + .sender + .as_deref() + .map(|sender| builder.create_string(sender)); + let receiver = interaction + .receiver + .as_deref() + .map(|receiver| builder.create_string(receiver)); + let message = interaction + .message + .as_deref() + .map(|message| builder.create_string(message)); + let source_location = serialize_source_location(builder, &interaction.source_location); + + fb::Interaction::create( + builder, + &fb::InteractionArgs { + sender, + receiver: receiver, + message, + source_location: Some(source_location), + }, + ) + } + + fn serialize_branch<'a>( + builder: &mut FlatBufferBuilder<'a>, + branch: &LogicBranch, + ) -> flatbuffers::WIPOffset> { + let case_offsets: Vec<_> = branch + .cases + .iter() + .map(|case| Self::serialize_branch_case(builder, case)) + .collect(); + let cases = builder.create_vector(&case_offsets); + + fb::Branch::create(builder, &fb::BranchArgs { cases: Some(cases) }) + } + + fn serialize_branch_case<'a>( + builder: &mut FlatBufferBuilder<'a>, + case: &LogicBranchCase, + ) -> flatbuffers::WIPOffset> { + let condition = case + .condition + .as_deref() + .map(|condition| builder.create_string(condition)); + let block = Self::serialize_block(builder, &case.block); + let source_location = serialize_source_location(builder, &case.source_location); + + fb::BranchCase::create( + builder, + &fb::BranchCaseArgs { + condition, + block: Some(block), + source_location: Some(source_location), + }, + ) + } + + fn serialize_loop<'a>( + builder: &mut FlatBufferBuilder<'a>, + loop_node: &LogicLoop, + ) -> flatbuffers::WIPOffset> { + let condition = loop_node + .condition + .as_deref() + .map(|condition| builder.create_string(condition)); + let block = Self::serialize_block(builder, &loop_node.block); + let source_location = serialize_source_location(builder, &loop_node.source_location); + + fb::Loop::create( + builder, + &fb::LoopArgs { + condition, + block: Some(block), + source_location: Some(source_location), + }, + ) + } + + fn serialize_parallel<'a>( + builder: &mut FlatBufferBuilder<'a>, + parallel: &LogicParallel, + ) -> flatbuffers::WIPOffset> { + let branch_offsets: Vec<_> = parallel + .branches + .iter() + .map(|branch| Self::serialize_parallel_branch(builder, branch)) + .collect(); + let branches = builder.create_vector(&branch_offsets); + + fb::Parallel::create( + builder, + &fb::ParallelArgs { + branches: Some(branches), + }, + ) + } + + fn serialize_parallel_branch<'a>( + builder: &mut FlatBufferBuilder<'a>, + branch: &LogicParallelBranch, + ) -> flatbuffers::WIPOffset> { + let label = branch + .label + .as_deref() + .map(|label| builder.create_string(label)); + let block = Self::serialize_block(builder, &branch.block); + let source_location = serialize_source_location(builder, &branch.source_location); + + fb::ParallelBranch::create( + builder, + &fb::ParallelBranchArgs { + label, + block: Some(block), + source_location: Some(source_location), + }, + ) + } + + fn serialize_early_exit<'a>( + builder: &mut FlatBufferBuilder<'a>, + early_exit: &LogicEarlyExit, + ) -> flatbuffers::WIPOffset> { + let reason = early_exit + .reason + .as_deref() + .map(|reason| builder.create_string(reason)); + let block = Self::serialize_block(builder, &early_exit.block); + let source_location = serialize_source_location(builder, &early_exit.source_location); - // Serialize the event union. - let (event_type, event_offset) = Self::serialize_event(builder, &node.event); + fb::EarlyExit::create( + builder, + &fb::EarlyExitArgs { + reason, + block: Some(block), + source_location: Some(source_location), + }, + ) + } + + fn serialize_lifecycle<'a>( + builder: &mut FlatBufferBuilder<'a>, + lifecycle: &ParticipantLifecycle, + ) -> flatbuffers::WIPOffset> { + let participant = builder.create_string(&lifecycle.participant); + let source_location = serialize_source_location(builder, &lifecycle.source_location); - fb::SequenceNode::create( + fb::ParticipantLifecycle::create( builder, - &fb::SequenceNodeArgs { - event_type, - event: Some(event_offset), + &fb::ParticipantLifecycleArgs { + participant: Some(participant), + action: map_lifecycle_action(lifecycle.action), source_location: Some(source_location), - branches_node: Some(branches_offset), }, ) } - fn serialize_event( - builder: &mut FlatBufferBuilder<'_>, - event: &Event, - ) -> ( - fb::Event, - flatbuffers::WIPOffset, - ) { - match event { - Event::Interaction(interaction) => { - let caller = builder.create_string(&interaction.caller); - let callee = builder.create_string(&interaction.callee); - let method = builder.create_string(&interaction.method); - let offset = fb::Interaction::create( - builder, - &fb::InteractionArgs { - caller: Some(caller), - callee: Some(callee), - method: Some(method), - }, - ); - (fb::Event::Interaction, offset.as_union_value()) - } - Event::Return(ret) => { - let caller = builder.create_string(&ret.caller); - let callee = builder.create_string(&ret.callee); - let return_content = builder.create_string(&ret.return_content); - let offset = fb::Return::create( - builder, - &fb::ReturnArgs { - caller: Some(caller), - callee: Some(callee), - return_content: Some(return_content), - }, - ); - (fb::Event::Return, offset.as_union_value()) - } - Event::Condition(cond) => { - let condition_value = builder.create_string(&cond.condition_value); - let offset = fb::Condition::create( - builder, - &fb::ConditionArgs { - condition_type: Self::map_condition_type(cond.condition_type.clone()), - condition_value: Some(condition_value), - }, - ); - (fb::Event::Condition, offset.as_union_value()) - } - } + fn serialize_reference<'a>( + builder: &mut FlatBufferBuilder<'a>, + reference: &Reference, + ) -> flatbuffers::WIPOffset> { + let participant_offsets: Vec<_> = reference + .participants + .iter() + .map(|participant| builder.create_string(participant)) + .collect(); + let participants = builder.create_vector(&participant_offsets); + let text = reference + .text + .as_deref() + .map(|text| builder.create_string(text)); + let source_location = serialize_source_location(builder, &reference.source_location); + + fb::Reference::create( + builder, + &fb::ReferenceArgs { + participants: Some(participants), + text, + source_location: Some(source_location), + }, + ) } +} + +fn serialize_source_location<'a>( + builder: &mut FlatBufferBuilder<'a>, + source_location: &SourceLocation, +) -> flatbuffers::WIPOffset> { + let file = builder.create_string(source_location.file.as_ref()); + fb::SourceLocation::create( + builder, + &fb::SourceLocationArgs { + file: Some(file), + line: source_location.line, + }, + ) +} - fn map_condition_type(ct: ConditionType) -> fb::ConditionType { - match ct { - ConditionType::Opt => fb::ConditionType::Opt, - ConditionType::Alt => fb::ConditionType::Alt, - ConditionType::Loop => fb::ConditionType::Loop, - ConditionType::Par => fb::ConditionType::Par, - ConditionType::Par2 => fb::ConditionType::Par2, - ConditionType::Break => fb::ConditionType::Break, - ConditionType::Critical => fb::ConditionType::Critical, - ConditionType::Else => fb::ConditionType::Else, - ConditionType::Also => fb::ConditionType::Also, - ConditionType::End => fb::ConditionType::End, - ConditionType::Group => fb::ConditionType::Group, - } +fn map_lifecycle_action(action: LifecycleAction) -> fb::LifecycleAction { + match action { + LifecycleAction::Create => fb::LifecycleAction::Create, + LifecycleAction::Activate => fb::LifecycleAction::Activate, + LifecycleAction::Deactivate => fb::LifecycleAction::Deactivate, + LifecycleAction::Destroy => fb::LifecycleAction::Destroy, } } diff --git a/validation/core/integration_test/sequence_internal_api/positive_internal_api_method_match/sequence_diagram.puml b/validation/core/integration_test/sequence_internal_api/positive_internal_api_method_match/sequence_diagram.puml index 32938db5..89780326 100644 --- a/validation/core/integration_test/sequence_internal_api/positive_internal_api_method_match/sequence_diagram.puml +++ b/validation/core/integration_test/sequence_internal_api/positive_internal_api_method_match/sequence_diagram.puml @@ -17,6 +17,6 @@ participant "Unit 1" as unit_1 <> participant "Unit 2" as unit_2 <> unit_1 -> unit_2 : GetData() -unit_2 --> unit_1 : Ack +return Ack @enduml diff --git a/validation/core/src/models/mod.rs b/validation/core/src/models/mod.rs index c518de95..e0d31986 100644 --- a/validation/core/src/models/mod.rs +++ b/validation/core/src/models/mod.rs @@ -32,5 +32,5 @@ pub use component_diagram_models::{ EndpointRole, LogicComponent, LogicComponentExt, LogicRelation, }; pub use sequence_diagram_models::{ - ObservedSequenceCall, SequenceDiagramIndex, SequenceDiagramInputs, + is_external_endpoint, ObservedSequenceCall, SequenceDiagramIndex, SequenceDiagramInputs, }; diff --git a/validation/core/src/models/sequence_diagram_models.rs b/validation/core/src/models/sequence_diagram_models.rs index 93b4ced0..73cae587 100644 --- a/validation/core/src/models/sequence_diagram_models.rs +++ b/validation/core/src/models/sequence_diagram_models.rs @@ -13,18 +13,23 @@ //! Models for sequence-diagram FlatBuffer inputs used by design verification. -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::BTreeMap; -use sequence_logic::{Event, SequenceNode, SequenceParticipant, SequenceTree}; -use source_location::SourceLocation; +use sequence_logic::{Block, Interaction, Node, SequenceTree, SourceLocation}; -use crate::{ErrorBuilder, ErrorCategory, ValidationResult}; +use crate::ValidationResult; /// Collection of sequence diagrams loaded from one or more FlatBuffer files. pub struct SequenceDiagramInputs { pub diagrams: Vec, } +const EXTERNAL_ENDPOINT_NAME: &str = "ExternalEndpoint"; + +pub fn is_external_endpoint(participant: &str) -> bool { + participant == EXTERNAL_ENDPOINT_NAME +} + /// One function-call interaction observed in a sequence diagram. pub struct ObservedSequenceCall { pub caller: String, @@ -42,271 +47,147 @@ impl SequenceDiagramInputs { /// Indexed sequence-diagram data prepared for validators. pub struct SequenceDiagramIndex { - used_participants: BTreeSet, + participants: BTreeMap, observed_calls: Vec, - participant_sources: BTreeMap, } impl SequenceDiagramIndex { fn from_diagrams(diagrams: &[SequenceTree], result: &mut ValidationResult) -> Self { - let mut used_participants = BTreeSet::new(); let mut observed_calls = Vec::new(); - let mut participant_sources = BTreeMap::new(); + let mut participants = BTreeMap::new(); for diagram in diagrams { - collect_participant_sources(&diagram.participants, &mut participant_sources); - for node in &diagram.root_interactions { - collect_sequence_data( - node, - &mut used_participants, - &mut observed_calls, - &mut participant_sources, - result, - ); + for participant in &diagram.participants { + let reference_name = participant + .alias + .as_deref() + .unwrap_or(&participant.display_name) + .to_string(); + + // Keep the first declaration location when a participant is + // declared in more than one input diagram. + participants + .entry(reference_name) + .or_insert_with(|| participant.source_location.clone()); } + + collect_block_data(&diagram.root, &mut observed_calls, result); } Self { - used_participants, + participants, observed_calls, - participant_sources, } } - pub fn used_participants(&self) -> &BTreeSet { - &self.used_participants + pub fn participants(&self) -> &BTreeMap { + &self.participants } pub fn observed_calls(&self) -> &[ObservedSequenceCall] { &self.observed_calls } - - pub fn participant_source(&self, participant: &str) -> Option<&SourceLocation> { - self.participant_sources.get(participant) - } } -fn collect_participant_sources( - participants: &[SequenceParticipant], - participant_sources: &mut BTreeMap, +fn collect_block_data( + block: &Block, + observed_calls: &mut Vec, + result: &mut ValidationResult, ) { - for participant in participants { - participant_sources - .entry(participant_name(participant)) - .or_insert_with(|| participant.source_location.clone()); + for node in &block.items { + collect_sequence_data(node, observed_calls, result); } } fn collect_sequence_data( - node: &SequenceNode, - used_participants: &mut BTreeSet, + node: &Node, observed_calls: &mut Vec, - participant_sources: &mut BTreeMap, result: &mut ValidationResult, ) { - match &node.event { - Event::Interaction(interaction) => { - let (source_file, source_line) = node.source_location.display(); - validate_required_endpoints( - result, - RequiredEndpointsCheck { - item_kind: "sequence function", - caller: interaction.caller.as_str(), - callee: interaction.callee.as_str(), - label_value: interaction.method.as_str(), - label_name: "method", - source_file: source_file.as_str(), - source_line, - }, - ); - - record_participant_usage_and_source( - interaction.caller.as_str(), - &node.source_location, - used_participants, - participant_sources, - ); - record_participant_usage_and_source( - interaction.callee.as_str(), - &node.source_location, - used_participants, - participant_sources, - ); - - observed_calls.push(ObservedSequenceCall { - caller: interaction.caller.clone(), - callee: interaction.callee.clone(), - method: interaction.method.clone(), - source_location: node.source_location.clone(), - }); + match node { + Node::Interaction(interaction) => { + observed_calls.push(observe_interaction(interaction)); } - Event::Return(ret) => { - let (source_file, source_line) = node.source_location.display(); - validate_required_endpoints( - result, - RequiredEndpointsCheck { - item_kind: "sequence return", - caller: ret.caller.as_str(), - callee: ret.callee.as_str(), - label_value: ret.return_content.as_str(), - label_name: "return content", - source_file: source_file.as_str(), - source_line, - }, - ); - - record_participant_usage_and_source( - ret.caller.as_str(), - &node.source_location, - used_participants, - participant_sources, - ); - record_participant_usage_and_source( - ret.callee.as_str(), - &node.source_location, - used_participants, - participant_sources, - ); + Node::Branch(branch) => { + for case in &branch.cases { + collect_block_data(&case.block, observed_calls, result); + } } - Event::Condition(_) => {} - } - - for child in &node.branches_node { - collect_sequence_data( - child, - used_participants, - observed_calls, - participant_sources, - result, - ); + Node::Loop(loop_node) => { + collect_block_data(&loop_node.block, observed_calls, result); + } + Node::Parallel(parallel) => { + for branch in ¶llel.branches { + collect_block_data(&branch.block, observed_calls, result); + } + } + Node::EarlyExit(early_exit) => { + collect_block_data(&early_exit.block, observed_calls, result); + } + Node::Lifecycle(_) | Node::Reference(_) => {} } } -fn record_participant_usage_and_source( - participant: &str, - source_location: &SourceLocation, - used_participants: &mut BTreeSet, - participant_sources: &mut BTreeMap, -) { - if participant.is_empty() { - return; - } - - used_participants.insert(participant.to_string()); - participant_sources - .entry(participant.to_string()) - .or_insert_with(|| source_location.clone()); -} - -fn participant_name(participant: &SequenceParticipant) -> String { - participant - .alias - .clone() - .filter(|alias| !alias.is_empty()) - .unwrap_or_else(|| participant.display_name.clone()) -} - -struct RequiredEndpointsCheck<'a> { - item_kind: &'a str, - caller: &'a str, - callee: &'a str, - label_value: &'a str, - label_name: &'a str, - source_file: &'a str, - source_line: u32, -} - -fn validate_required_endpoints(result: &mut ValidationResult, check: RequiredEndpointsCheck<'_>) { - let RequiredEndpointsCheck { - item_kind, - caller, - callee, - label_value, - label_name, - source_file, - source_line, - } = check; - - if !caller.is_empty() && !callee.is_empty() { - return; - } - - let missing_endpoints = match (caller.is_empty(), callee.is_empty()) { - (true, true) => "caller and callee", - (true, false) => "caller", - (false, true) => "callee", - (false, false) => unreachable!(), +fn observe_interaction(interaction: &Interaction) -> ObservedSequenceCall { + let observed_call = ObservedSequenceCall { + caller: interaction + .sender + .as_deref() + .unwrap_or(EXTERNAL_ENDPOINT_NAME) + .to_string(), + callee: interaction + .receiver + .as_deref() + .unwrap_or(EXTERNAL_ENDPOINT_NAME) + .to_string(), + method: interaction.message.clone().unwrap_or_default(), + source_location: interaction.source_location.clone(), }; - let fix = format!( - "add the missing {missing_endpoints} for {item_kind} \"{label_value}\" in the sequence diagram" - ); - - result.add_failure( - ErrorBuilder::new(ErrorCategory::Method) - .title(format!( - "{item_kind} \"{label_value}\" is missing {missing_endpoints}." - )) - .field(label_name, format!("\"{label_value}\"")) - .field("caller unit", format!("\"{caller}\"")) - .field("callee unit", format!("\"{callee}\"")) - .field("sequence source file", format!("\"{source_file}\"")) - .field("sequence source line", source_line.to_string()) - .fix(fix) - .build(), - ); + observed_call } #[cfg(test)] mod tests { use super::*; use crate::validators::fixtures::dummy_source_location; - use sequence_logic::{Interaction, Return}; - - fn interaction( - caller: &str, - callee: &str, - method: &str, - branches_node: Vec, - ) -> SequenceNode { - SequenceNode { - event: Event::Interaction(Interaction { - caller: caller.to_string(), - callee: callee.to_string(), - method: method.to_string(), - }), + use sequence_logic::{Branch, BranchCase, Interaction}; + + fn interaction(caller: Option<&str>, callee: Option<&str>, method: &str) -> Node { + Node::Interaction(Interaction { + sender: caller.map(|caller| caller.to_string().into()), + receiver: callee.map(|callee| callee.to_string().into()), + message: Some(method.to_string()), source_location: dummy_source_location(), - branches_node, - } + }) } - fn ret(caller: &str, callee: &str) -> SequenceNode { - SequenceNode { - event: Event::Return(Return { - caller: caller.to_string(), - callee: callee.to_string(), - return_content: String::new(), - }), - source_location: dummy_source_location(), - branches_node: Vec::new(), - } + fn branch(items: Vec) -> Node { + Node::Branch(Branch { + cases: vec![BranchCase { + condition: Some("case".to_string()), + block: Block { items }, + source_location: dummy_source_location(), + }], + }) } #[test] - fn sequence_index_collects_calls_and_used_participants_recursively() { + fn sequence_index_collects_nested_calls_recursively() { let inputs = SequenceDiagramInputs { diagrams: vec![SequenceTree { name: Some("seq".to_string()), participants: Vec::new(), - root_interactions: vec![interaction( - "unit_1", - "unit_2", - "GetData()", - vec![ - ret("unit_1", "unit_2"), - interaction("unit_2", "unit_3", "Forward()", Vec::new()), + root: Block { + items: vec![ + interaction(Some("unit_1"), Some("unit_2"), "GetData()"), + branch(vec![interaction( + Some("unit_2"), + Some("unit_3"), + "Forward()", + )]), ], - )], + }, }], }; @@ -314,14 +195,6 @@ mod tests { let index = inputs.to_sequence_diagram_index(&mut result); assert!(result.is_empty()); - assert_eq!( - index.used_participants(), - &BTreeSet::from([ - "unit_1".to_string(), - "unit_2".to_string(), - "unit_3".to_string(), - ]) - ); assert_eq!(index.observed_calls().len(), 2); assert_eq!(index.observed_calls()[0].caller, "unit_1"); assert_eq!(index.observed_calls()[0].callee, "unit_2"); @@ -332,40 +205,42 @@ mod tests { } #[test] - fn sequence_index_reports_interaction_with_missing_required_endpoints() { + fn sequence_index_maps_missing_caller_to_external_endpoint() { let inputs = SequenceDiagramInputs { diagrams: vec![SequenceTree { name: Some("seq".to_string()), participants: Vec::new(), - root_interactions: vec![interaction("", "unit_2", "GetData()", Vec::new())], + root: Block { + items: vec![interaction(None, Some("unit_2"), "GetData()")], + }, }], }; let mut result = ValidationResult::default(); - let _index = inputs.to_sequence_diagram_index(&mut result); + let index = inputs.to_sequence_diagram_index(&mut result); - assert_eq!(result.failures.len(), 1); - assert!(result.failures[0] - .contains("[Method] Sequence function \"GetData()\" is missing caller.")); - assert!(result.failures[0].contains("\"unit_2\"")); + assert!(result.is_empty()); + assert_eq!(index.observed_calls()[0].caller, EXTERNAL_ENDPOINT_NAME); + assert_eq!(index.observed_calls()[0].callee, "unit_2"); } #[test] - fn sequence_index_reports_interaction_with_missing_callee() { + fn sequence_index_maps_missing_callee_to_external_endpoint() { let inputs = SequenceDiagramInputs { diagrams: vec![SequenceTree { name: Some("seq".to_string()), participants: Vec::new(), - root_interactions: vec![interaction("unit_1", "", "GetData()", Vec::new())], + root: Block { + items: vec![interaction(Some("unit_1"), None, "GetData()")], + }, }], }; let mut result = ValidationResult::default(); - let _index = inputs.to_sequence_diagram_index(&mut result); + let index = inputs.to_sequence_diagram_index(&mut result); - assert_eq!(result.failures.len(), 1); - assert!(result.failures[0] - .contains("[Method] Sequence function \"GetData()\" is missing callee.")); - assert!(result.failures[0].contains("\"unit_1\"")); + assert!(result.is_empty()); + assert_eq!(index.observed_calls()[0].caller, "unit_1"); + assert_eq!(index.observed_calls()[0].callee, EXTERNAL_ENDPOINT_NAME); } } diff --git a/validation/core/src/readers/sequence_diagram_reader.rs b/validation/core/src/readers/sequence_diagram_reader.rs index fc9fbc76..c2360578 100644 --- a/validation/core/src/readers/sequence_diagram_reader.rs +++ b/validation/core/src/readers/sequence_diagram_reader.rs @@ -17,8 +17,9 @@ use std::fs; use sequence_fbs::sequence_metamodel as fb_sequence; use sequence_logic::{ - Condition, ConditionType, Event, Interaction, ParticipantType, Return, SequenceNode, - SequenceParticipant, SequenceTree, + Block, Branch, BranchCase, EarlyExit, Interaction, LifecycleAction, Loop, Node, Parallel, + ParallelBranch, ParticipantLifecycle, ParticipantType, Reference, SequenceParticipant, + SequenceTree, }; use crate::models::SequenceDiagramInputs; @@ -62,18 +63,8 @@ impl Reader for SequenceDiagramReader { let diagram = flatbuffers::root::(&data) .map_err(|e| format!("Failed to parse sequence FlatBuffer {path}: {e}"))?; - let root_interactions = if let Some(nodes) = diagram.root_interactions() { - let mut parsed_nodes = Vec::with_capacity(nodes.len()); - for (index, node) in nodes.iter().enumerate() { - parsed_nodes.push( - read_node(node, &format!("{path}:root[{index}]")) - .map_err(|e| format!("Failed to parse sequence node: {e}"))?, - ); - } - parsed_nodes - } else { - Vec::new() - }; + let root = read_block(diagram.root(), &format!("{path}:root")) + .map_err(|e| format!("Failed to parse sequence root block: {e}"))?; let participants = if let Some(values) = diagram.participants() { let mut parsed_participants = Vec::with_capacity(values.len()); @@ -91,7 +82,7 @@ impl Reader for SequenceDiagramReader { diagrams.push(SequenceTree { name: diagram.name().map(|s| s.to_string()), participants, - root_interactions, + root, }); } @@ -99,77 +90,165 @@ impl Reader for SequenceDiagramReader { } } -fn read_node(node: fb_sequence::SequenceNode<'_>, node_path: &str) -> Result { - let event = match node.event_type() { - fb_sequence::Event::Interaction => { - let interaction = node.event_as_interaction().ok_or_else(|| { - format!( - "{node_path}: event_type is Interaction, but interaction payload is missing" - ) +fn read_block(block: fb_sequence::Block<'_>, block_path: &str) -> Result { + let mut items = Vec::new(); + if let Some(values) = block.items() { + items.reserve(values.len()); + for (index, item) in values.iter().enumerate() { + items.push(read_node_item( + item, + &format!("{block_path}.items[{index}]"), + )?); + } + } + + Ok(Block { items }) +} + +fn read_node_item(item: fb_sequence::NodeItem<'_>, node_path: &str) -> Result { + match item.node_type() { + fb_sequence::Node::Interaction => { + let interaction = item.node_as_interaction().ok_or_else(|| { + format!("{node_path}: node_type is Interaction, but payload is missing") })?; - Event::Interaction(Interaction { - caller: interaction.caller().to_string(), - callee: interaction.callee().to_string(), - method: interaction - .method() - .map(|s| s.to_string()) - .unwrap_or_default(), - }) + Ok(Node::Interaction(Interaction { + sender: interaction.sender().map(|sender| sender.to_string().into()), + receiver: interaction + .receiver() + .map(|receiver| receiver.to_string().into()), + message: interaction.message().map(|s| s.to_string()), + source_location: to_source_location( + interaction.source_location().file(), + interaction.source_location().line(), + ), + })) } - fb_sequence::Event::Return => { - let ret = node.event_as_return().ok_or_else(|| { - format!("{node_path}: event_type is Return, but return payload is missing") + fb_sequence::Node::Branch => { + let branch = item.node_as_branch().ok_or_else(|| { + format!("{node_path}: node_type is Branch, but payload is missing") })?; - Event::Return(Return { - caller: ret.caller().to_string(), - callee: ret.callee().to_string(), - return_content: ret - .return_content() - .map(|s| s.to_string()) - .unwrap_or_default(), - }) + Ok(Node::Branch(read_branch(branch, node_path)?)) + } + fb_sequence::Node::Loop => { + let loop_node = item + .node_as_loop() + .ok_or_else(|| format!("{node_path}: node_type is Loop, but payload is missing"))?; + Ok(Node::Loop(Loop { + condition: loop_node.condition().map(|s| s.to_string()), + block: read_block(loop_node.block(), &format!("{node_path}.block"))?, + source_location: to_source_location( + loop_node.source_location().file(), + loop_node.source_location().line(), + ), + })) } - fb_sequence::Event::Condition => { - let condition = node.event_as_condition().ok_or_else(|| { - format!("{node_path}: event_type is Condition, but condition payload is missing") + fb_sequence::Node::Parallel => { + let parallel = item.node_as_parallel().ok_or_else(|| { + format!("{node_path}: node_type is Parallel, but payload is missing") })?; - Event::Condition(Condition { - condition_type: map_condition_type(condition.condition_type(), node_path)?, - condition_value: condition - .condition_value() - .map(|s| s.to_string()) - .unwrap_or_default(), - }) + Ok(Node::Parallel(read_parallel(parallel, node_path)?)) } - fb_sequence::Event::NONE => { - return Err(format!("{node_path}: event_type is NONE")); + fb_sequence::Node::EarlyExit => { + let early_exit = item.node_as_early_exit().ok_or_else(|| { + format!("{node_path}: node_type is EarlyExit, but payload is missing") + })?; + Ok(Node::EarlyExit(EarlyExit { + reason: early_exit.reason().map(|s| s.to_string()), + block: read_block(early_exit.block(), &format!("{node_path}.block"))?, + source_location: to_source_location( + early_exit.source_location().file(), + early_exit.source_location().line(), + ), + })) } - _ => { - return Err(format!( - "{node_path}: unsupported event_type {:?}", - node.event_type() - )); + fb_sequence::Node::ParticipantLifecycle => { + let lifecycle = item.node_as_participant_lifecycle().ok_or_else(|| { + format!("{node_path}: node_type is ParticipantLifecycle, but payload is missing") + })?; + Ok(Node::Lifecycle(ParticipantLifecycle { + participant: lifecycle.participant().to_string().into(), + action: map_lifecycle_action(lifecycle.action()) + .map_err(|err| format!("{node_path}: {err}"))?, + source_location: to_source_location( + lifecycle.source_location().file(), + lifecycle.source_location().line(), + ), + })) } - }; + fb_sequence::Node::Reference => { + let reference = item.node_as_reference().ok_or_else(|| { + format!("{node_path}: node_type is Reference, but payload is missing") + })?; + Ok(Node::Reference(read_reference(reference))) + } + fb_sequence::Node::NONE => Err(format!("{node_path}: node_type is NONE")), + other => Err(format!("{node_path}: unsupported node_type {other:?}")), + } +} + +fn read_branch(branch: fb_sequence::Branch<'_>, branch_path: &str) -> Result { + let mut cases = Vec::new(); + if let Some(values) = branch.cases() { + cases.reserve(values.len()); + for (index, case) in values.iter().enumerate() { + cases.push(BranchCase { + condition: case.condition().map(|s| s.to_string()), + block: read_block(case.block(), &format!("{branch_path}.cases[{index}].block"))?, + source_location: to_source_location( + case.source_location().file(), + case.source_location().line(), + ), + }); + } + } + + Ok(Branch { cases }) +} - let branches_node = if let Some(children) = node.branches_node() { - let mut parsed_children = Vec::with_capacity(children.len()); - for (index, child) in children.iter().enumerate() { - parsed_children.push(read_node(child, &format!("{node_path}.branches[{index}]"))?); +fn read_parallel( + parallel: fb_sequence::Parallel<'_>, + parallel_path: &str, +) -> Result { + let mut branches = Vec::new(); + if let Some(values) = parallel.branches() { + branches.reserve(values.len()); + for (index, branch) in values.iter().enumerate() { + branches.push(ParallelBranch { + label: branch.label().map(|s| s.to_string()), + block: read_block( + branch.block(), + &format!("{parallel_path}.branches[{index}].block"), + )?, + source_location: to_source_location( + branch.source_location().file(), + branch.source_location().line(), + ), + }); } - parsed_children - } else { - Vec::new() - }; + } + + Ok(Parallel { branches }) +} - Ok(SequenceNode { - event, +fn read_reference(reference: fb_sequence::Reference<'_>) -> Reference { + let participants = reference + .participants() + .map(|values| { + values + .iter() + .map(|value| value.to_string().into()) + .collect() + }) + .unwrap_or_default(); + + Reference { + participants, + text: reference.text().map(|s| s.to_string()), source_location: to_source_location( - node.source_location().file(), - node.source_location().line(), + reference.source_location().file(), + reference.source_location().line(), ), - branches_node, - }) + } } fn read_participant( @@ -189,25 +268,12 @@ fn read_participant( }) } -fn map_condition_type( - value: fb_sequence::ConditionType, - node_path: &str, -) -> Result { +fn map_lifecycle_action(value: fb_sequence::LifecycleAction) -> Result { match value { - fb_sequence::ConditionType::Opt => Ok(ConditionType::Opt), - fb_sequence::ConditionType::Alt => Ok(ConditionType::Alt), - fb_sequence::ConditionType::Loop => Ok(ConditionType::Loop), - fb_sequence::ConditionType::Par => Ok(ConditionType::Par), - fb_sequence::ConditionType::Par2 => Ok(ConditionType::Par2), - fb_sequence::ConditionType::Break => Ok(ConditionType::Break), - fb_sequence::ConditionType::Critical => Ok(ConditionType::Critical), - fb_sequence::ConditionType::Else => Ok(ConditionType::Else), - fb_sequence::ConditionType::Also => Ok(ConditionType::Also), - fb_sequence::ConditionType::End => Ok(ConditionType::End), - fb_sequence::ConditionType::Group => Ok(ConditionType::Group), - _ => Err(format!( - "{node_path}: unsupported condition_type {:?}", - value - )), + fb_sequence::LifecycleAction::Create => Ok(LifecycleAction::Create), + fb_sequence::LifecycleAction::Activate => Ok(LifecycleAction::Activate), + fb_sequence::LifecycleAction::Deactivate => Ok(LifecycleAction::Deactivate), + fb_sequence::LifecycleAction::Destroy => Ok(LifecycleAction::Destroy), + other => Err(format!("unsupported lifecycle action {other:?}")), } } diff --git a/validation/core/src/validators/component_sequence_validator.rs b/validation/core/src/validators/component_sequence_validator.rs index f5897b5e..06febb2f 100644 --- a/validation/core/src/validators/component_sequence_validator.rs +++ b/validation/core/src/validators/component_sequence_validator.rs @@ -16,11 +16,13 @@ use std::collections::{BTreeMap, BTreeSet}; +use sequence_logic::SourceLocation; + use super::shared::{ build_observed_call_contexts, build_unit_bindings, format_name_list, intersect_interfaces, SequenceCallContext, UnitBindings, }; -use crate::models::{ComponentDiagramArchitecture, SequenceDiagramIndex}; +use crate::models::{is_external_endpoint, ComponentDiagramArchitecture, SequenceDiagramIndex}; use crate::results::{ErrorBuilder, ErrorCategory}; use crate::{Diagnostics, ValidationResult}; @@ -34,13 +36,10 @@ pub fn validate_component_sequence( type ConnectedUnitPairs = BTreeMap<(String, String), BTreeSet>; -const EXTERNAL_ENDPOINT_NAME: &str = "ExternalEndpoint"; - struct ComponentSequenceValidator<'a> { - observed_participants: &'a BTreeSet, + participants: &'a BTreeMap, observed_call_contexts: Vec>, connected_unit_pairs: ConnectedUnitPairs, - sequence_diagram: &'a SequenceDiagramIndex, unit_bindings: UnitBindings, result: ValidationResult, } @@ -89,10 +88,9 @@ impl<'a> ComponentSequenceValidator<'a> { build_observed_call_contexts(sequence_diagram.observed_calls(), &unit_bindings); Self { - observed_participants: sequence_diagram.used_participants(), + participants: sequence_diagram.participants(), observed_call_contexts, connected_unit_pairs: build_connected_unit_pairs(&unit_bindings), - sequence_diagram, unit_bindings, result: ValidationResult::default(), } @@ -101,7 +99,7 @@ impl<'a> ComponentSequenceValidator<'a> { fn run(mut self) -> ValidationResult { append_debug_log( &mut self.result.diagnostics, - self.observed_participants, + self.participants.keys(), &self.observed_call_contexts, &self.unit_bindings, &self.connected_unit_pairs, @@ -120,7 +118,7 @@ impl<'a> ComponentSequenceValidator<'a> { for alias in self .unit_bindings .keys() - .filter(|alias| !self.observed_participants.contains(*alias)) + .filter(|alias| !self.participants.contains_key(*alias)) { let (source_file, source_line) = self .unit_bindings @@ -144,14 +142,10 @@ impl<'a> ComponentSequenceValidator<'a> { ); } - for participant in self.observed_participants.iter().filter(|participant| { + for participant in self.participants.keys().filter(|participant| { !is_external_endpoint(participant) && !self.unit_bindings.contains_key(*participant) }) { - let (source_file, source_line) = self - .sequence_diagram - .participant_source(participant) - .map(|source_location| source_location.display()) - .unwrap_or_default(); + let (source_file, source_line) = self.participants[participant].display(); self.result.add_failure( ErrorBuilder::new(ErrorCategory::Naming) @@ -276,17 +270,13 @@ impl<'a> ComponentSequenceValidator<'a> { } } -fn is_external_endpoint(participant: &str) -> bool { - participant == EXTERNAL_ENDPOINT_NAME -} - fn call_involves_external_endpoint(call_context: &SequenceCallContext<'_>) -> bool { is_external_endpoint(call_context.caller_unit) || is_external_endpoint(call_context.callee_unit) } -fn append_debug_log( +fn append_debug_log<'a>( diagnostics: &mut Diagnostics, - observed_participants: &BTreeSet, + observed_participants: impl Iterator, observed_call_contexts: &[SequenceCallContext<'_>], unit_bindings: &UnitBindings, connected_unit_pairs: &BTreeMap<(String, String), BTreeSet>, diff --git a/validation/core/src/validators/test/fixtures.rs b/validation/core/src/validators/test/fixtures.rs index f07eb517..0d95b149 100644 --- a/validation/core/src/validators/test/fixtures.rs +++ b/validation/core/src/validators/test/fixtures.rs @@ -16,10 +16,10 @@ use crate::models::{ LogicComponent, LogicRelation, SequenceDiagramInputs, }; use class_diagram::{ClassDiagram, EntityType, Method, SimpleEntity, Visibility}; +use component_diagram::SourceLocation; use sequence_logic::{ - Event, Interaction, ParticipantType, SequenceNode, SequenceParticipant, SequenceTree, + Block, Interaction, Node, ParticipantType, SequenceParticipant, SequenceTree, }; -use source_location::SourceLocation; // Common fixtures @@ -121,60 +121,57 @@ fn logic_component( // Sequence diagram fixtures. pub(super) fn sequence_diagrams(participants: &[&str]) -> SequenceDiagramInputs { - sequence_calls( - &participants - .iter() - .map(|participant| (*participant, *participant, "")) - .collect::>(), - ) -} - -pub(super) fn sequence_calls(calls: &[(&str, &str, &str)]) -> SequenceDiagramInputs { SequenceDiagramInputs { diagrams: vec![SequenceTree { name: Some("seq".to_string()), - participants: sequence_participants(calls), - root_interactions: calls + participants: participants .iter() - .map(|(caller, callee, method)| SequenceNode { - event: Event::Interaction(Interaction { - caller: (*caller).to_string(), - callee: (*callee).to_string(), - method: (*method).to_string(), - }), - source_location: dummy_source_location(), - branches_node: Vec::new(), - }) + .map(|participant| sequence_participant(participant)) .collect(), + root: Block::default(), }], } } -fn sequence_participants(calls: &[(&str, &str, &str)]) -> Vec { - let mut participants: Vec = Vec::new(); - - for name in calls +pub(super) fn sequence_calls(calls: &[(&str, &str, &str)]) -> SequenceDiagramInputs { + let participants = calls .iter() .flat_map(|(caller, callee, _)| [*caller, *callee]) - { - if name.is_empty() - || participants - .iter() - .any(|participant| participant.display_name == name) - { - continue; - } - - participants.push(SequenceParticipant { - display_name: name.to_string(), - alias: Some(name.to_string()), - participant_type: ParticipantType::Participant, - source_location: dummy_source_location(), - stereotype: None, - }); + .filter(|participant| !participant.is_empty()) + .collect::>() + .into_iter() + .map(sequence_participant) + .collect(); + + SequenceDiagramInputs { + diagrams: vec![SequenceTree { + name: Some("seq".to_string()), + participants, + root: Block { + items: calls + .iter() + .map(|(caller, callee, method)| { + Node::Interaction(Interaction { + sender: Some((*caller).to_string().into()), + receiver: Some((*callee).to_string().into()), + message: Some((*method).to_string()), + source_location: dummy_source_location(), + }) + }) + .collect(), + }, + }], } +} - participants +fn sequence_participant(participant: &str) -> SequenceParticipant { + SequenceParticipant { + display_name: participant.to_string(), + alias: None, + participant_type: ParticipantType::Participant, + source_location: dummy_source_location(), + stereotype: None, + } } // Class diagram API fixtures.