diff --git a/plantuml/parser/integration_test/activity_diagram/BUILD b/plantuml/parser/integration_test/activity_diagram/BUILD new file mode 100644 index 0000000..ccbbe99 --- /dev/null +++ b/plantuml/parser/integration_test/activity_diagram/BUILD @@ -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 +# ******************************************************************************* +filegroup( + name = "activity_diagram_files", + srcs = glob( + [ + "**/*.puml", + "**/*.json", + "**/*.yaml", + ], + allow_empty = True, + ), + visibility = ["//visibility:public"], +) diff --git a/plantuml/parser/integration_test/activity_diagram/activity_diagram.puml b/plantuml/parser/integration_test/activity_diagram/activity_diagram.puml new file mode 100644 index 0000000..4d88693 --- /dev/null +++ b/plantuml/parser/integration_test/activity_diagram/activity_diagram.puml @@ -0,0 +1,59 @@ +' ******************************************************************************* +' 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 test_activity_beta_syntax +title "Beta Activity Syntax Coverage" + +start + +:Initialize parser context; +note left: Basic action and note + +if (fatal configuration error?) then (yes) + end +else (no) + :Continue setup; +endif + +if (beta syntax enabled?) then (yes) + :Enter beta flow; + if (extra validation needed?) then (yes) + :Run nested validation; + else (no) + :Skip nested validation; + endif +else (no) + :Fallback configuration path; +endif + +while (retry budget available?) is (yes) + :Try parse step; + if (parse step succeeded?) then (yes) + break + else (no) + :Update retry state; + endif +endwhile + +repeat + #LightBlue:Read next activity token; + if (token available?) then (yes) + :Consume token; + else (no) + break + endif +repeat while (more tokens expected?) is (yes) + +:Finalize result; +stop + +@enduml diff --git a/plantuml/parser/integration_test/activity_diagram/parser/action_separated/action_separated.puml b/plantuml/parser/integration_test/activity_diagram/parser/action_separated/action_separated.puml new file mode 100644 index 0000000..b6699af --- /dev/null +++ b/plantuml/parser/integration_test/activity_diagram/parser/action_separated/action_separated.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 action_separated + +- A1 +- A2 +- A3 + +@enduml diff --git a/plantuml/parser/integration_test/activity_diagram/parser/action_separated/output.json b/plantuml/parser/integration_test/activity_diagram/parser/action_separated/output.json new file mode 100644 index 0000000..e260d70 --- /dev/null +++ b/plantuml/parser/integration_test/activity_diagram/parser/action_separated/output.json @@ -0,0 +1,22 @@ +{ + "action_separated.puml": { + "name": "action_separated", + "statements": [ + { + "Action": { + "label": "A1" + } + }, + { + "Action": { + "label": "A2" + } + }, + { + "Action": { + "label": "A3" + } + } + ] + } +} diff --git a/plantuml/parser/integration_test/activity_diagram/parser/action_simple/action_simple.puml b/plantuml/parser/integration_test/activity_diagram/parser/action_simple/action_simple.puml new file mode 100644 index 0000000..2d5f681 --- /dev/null +++ b/plantuml/parser/integration_test/activity_diagram/parser/action_simple/action_simple.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 action_simple + +:Hello ""activity diagram""; +:This block is **defined** on +__several__ ~~lines~~; + +@enduml diff --git a/plantuml/parser/integration_test/activity_diagram/parser/action_simple/output.json b/plantuml/parser/integration_test/activity_diagram/parser/action_simple/output.json new file mode 100644 index 0000000..849d194 --- /dev/null +++ b/plantuml/parser/integration_test/activity_diagram/parser/action_simple/output.json @@ -0,0 +1,17 @@ +{ + "action_simple.puml": { + "name": "action_simple", + "statements": [ + { + "Action": { + "label": "Hello activity diagram" + } + }, + { + "Action": { + "label": "This block is defined on\nseveral lines" + } + } + ] + } +} diff --git a/plantuml/parser/integration_test/activity_diagram/parser/arrows/arrows.puml b/plantuml/parser/integration_test/activity_diagram/parser/arrows/arrows.puml new file mode 100644 index 0000000..390350f --- /dev/null +++ b/plantuml/parser/integration_test/activity_diagram/parser/arrows/arrows.puml @@ -0,0 +1,31 @@ +' ******************************************************************************* +' 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 arrows + +:foo1; +-> You can put text on arrows; +if (test) then + -[#blue]-> + :foo2; + -[#green,dashed]-> The text can + also be on several lines + and **very** long...; + :foo3; +else + -[#black,dotted]-> + :foo4; +endif +-[#gray,bold]-> +:foo5; + +@enduml diff --git a/plantuml/parser/integration_test/activity_diagram/parser/arrows/output.json b/plantuml/parser/integration_test/activity_diagram/parser/arrows/output.json new file mode 100644 index 0000000..0575166 --- /dev/null +++ b/plantuml/parser/integration_test/activity_diagram/parser/arrows/output.json @@ -0,0 +1,76 @@ +{ + "arrows.puml": { + "name": "arrows", + "statements": [ + { + "Action": { + "label": "foo1" + } + }, + { + "Arrow": { + "syntax": "->", + "label": "You can put text on arrows" + } + }, + { + "IfStart": { + "condition": "test", + "label": null + } + }, + { + "Arrow": { + "syntax": "-[#blue]->", + "label": null + } + }, + { + "Action": { + "label": "foo2" + } + }, + { + "Arrow": { + "syntax": "-[#green,dashed]->", + "label": "The text can\nalso be on several lines\nand very long..." + } + }, + { + "Action": { + "label": "foo3" + } + }, + { + "Else": { + "label": null + } + }, + { + "Arrow": { + "syntax": "-[#black,dotted]->", + "label": null + } + }, + { + "Action": { + "label": "foo4" + } + }, + { + "EndIf": null + }, + { + "Arrow": { + "syntax": "-[#gray,bold]->", + "label": null + } + }, + { + "Action": { + "label": "foo5" + } + } + ] + } +} diff --git a/plantuml/parser/integration_test/activity_diagram/parser/color/color.puml b/plantuml/parser/integration_test/activity_diagram/parser/color/color.puml new file mode 100644 index 0000000..db2ba88 --- /dev/null +++ b/plantuml/parser/integration_test/activity_diagram/parser/color/color.puml @@ -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 +' ******************************************************************************* +@startuml color + +#YellowGreen:Hello Activity Diagram; + +@enduml diff --git a/plantuml/parser/integration_test/activity_diagram/parser/color/output.json b/plantuml/parser/integration_test/activity_diagram/parser/color/output.json new file mode 100644 index 0000000..f03e589 --- /dev/null +++ b/plantuml/parser/integration_test/activity_diagram/parser/color/output.json @@ -0,0 +1,12 @@ +{ + "color.puml": { + "name": "color", + "statements": [ + { + "Action": { + "label": "Hello Activity Diagram" + } + } + ] + } +} diff --git a/plantuml/parser/integration_test/activity_diagram/parser/fork/fork.puml b/plantuml/parser/integration_test/activity_diagram/parser/fork/fork.puml new file mode 100644 index 0000000..211f044 --- /dev/null +++ b/plantuml/parser/integration_test/activity_diagram/parser/fork/fork.puml @@ -0,0 +1,28 @@ +' ******************************************************************************* +' 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 fork + +start +fork + :A; +fork again + :B; + end +fork again + :C; +fork again + :D; +end fork +stop + +@enduml diff --git a/plantuml/parser/integration_test/activity_diagram/parser/fork/output.json b/plantuml/parser/integration_test/activity_diagram/parser/fork/output.json new file mode 100644 index 0000000..14f7a1e --- /dev/null +++ b/plantuml/parser/integration_test/activity_diagram/parser/fork/output.json @@ -0,0 +1,54 @@ +{ + "fork.puml": { + "name": "fork", + "statements": [ + { + "Start": null + }, + { + "ForkStart": null + }, + { + "Action": { + "label": "A" + } + }, + { + "ForkAgain": null + }, + { + "Action": { + "label": "B" + } + }, + { + "Stop": null + }, + { + "ForkAgain": null + }, + { + "Action": { + "label": "C" + } + }, + { + "ForkAgain": null + }, + { + "Action": { + "label": "D" + } + }, + { + "ForkEnd": { + "kind": "EndFork", + "modifier": null + } + }, + { + "Stop": null + } + ] + } +} diff --git a/plantuml/parser/integration_test/activity_diagram/parser/if/if.puml b/plantuml/parser/integration_test/activity_diagram/parser/if/if.puml new file mode 100644 index 0000000..64f182e --- /dev/null +++ b/plantuml/parser/integration_test/activity_diagram/parser/if/if.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 if + +if (beta syntax enabled?) then + :Enter beta flow; + if (extra validation needed?) then (yes) + :Run nested validation; + else (no) + :Skip nested validation; + stop + endif +else (no) + :Fallback configuration path; + kill +endif + +stop + +@enduml diff --git a/plantuml/parser/integration_test/activity_diagram/parser/if/output.json b/plantuml/parser/integration_test/activity_diagram/parser/if/output.json new file mode 100644 index 0000000..d91598d --- /dev/null +++ b/plantuml/parser/integration_test/activity_diagram/parser/if/output.json @@ -0,0 +1,66 @@ +{ + "if.puml": { + "name": "if", + "statements": [ + { + "IfStart": { + "condition": "beta syntax enabled?", + "label": null + } + }, + { + "Action": { + "label": "Enter beta flow" + } + }, + { + "IfStart": { + "condition": "extra validation needed?", + "label": "yes" + } + }, + { + "Action": { + "label": "Run nested validation" + } + }, + { + "Else": { + "label": "no" + } + }, + { + "Action": { + "label": "Skip nested validation" + } + }, + { + "Stop": null + }, + { + "EndIf": null + }, + { + "Else": { + "label": "no" + } + }, + { + "Action": { + "label": "Fallback configuration path" + } + }, + { + "Control": { + "kind": "Kill" + } + }, + { + "EndIf": null + }, + { + "Stop": null + } + ] + } +} diff --git a/plantuml/parser/integration_test/activity_diagram/parser/repeat_while/output.json b/plantuml/parser/integration_test/activity_diagram/parser/repeat_while/output.json new file mode 100644 index 0000000..55848ed --- /dev/null +++ b/plantuml/parser/integration_test/activity_diagram/parser/repeat_while/output.json @@ -0,0 +1,37 @@ +{ + "repeat_while.puml": { + "name": "repeat_while", + "statements": [ + { + "Start": null + }, + { + "Action": { + "label": "i = 0\\nj = 0" + } + }, + { + "RepeatStart": null + }, + { + "Action": { + "label": "i++" + } + }, + { + "Backward": { + "label": "j = i" + } + }, + { + "RepeatWhile": { + "condition": "i <= 10?", + "label": "yes" + } + }, + { + "Stop": null + } + ] + } +} diff --git a/plantuml/parser/integration_test/activity_diagram/parser/repeat_while/repeat_while.puml b/plantuml/parser/integration_test/activity_diagram/parser/repeat_while/repeat_while.puml new file mode 100644 index 0000000..4b3c78a --- /dev/null +++ b/plantuml/parser/integration_test/activity_diagram/parser/repeat_while/repeat_while.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 repeat_while + +start + +:i = 0\nj = 0; +repeat + :i++; +backward: j = i; +note left: this is note +repeat while (i <= 10?) is (yes) + +stop + +@enduml diff --git a/plantuml/parser/integration_test/activity_diagram/parser/start_stop/output.json b/plantuml/parser/integration_test/activity_diagram/parser/start_stop/output.json new file mode 100644 index 0000000..df9fecc --- /dev/null +++ b/plantuml/parser/integration_test/activity_diagram/parser/start_stop/output.json @@ -0,0 +1,23 @@ +{ + "start_stop.puml": { + "name": "start_stop", + "statements": [ + { + "Start": null + }, + { + "Action": { + "label": "Hello activity diagram" + } + }, + { + "Action": { + "label": "This is defined on\nseveral lines" + } + }, + { + "Stop": null + } + ] + } +} diff --git a/plantuml/parser/integration_test/activity_diagram/parser/start_stop/start_stop.puml b/plantuml/parser/integration_test/activity_diagram/parser/start_stop/start_stop.puml new file mode 100644 index 0000000..3a15530 --- /dev/null +++ b/plantuml/parser/integration_test/activity_diagram/parser/start_stop/start_stop.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 start_stop + +start +:Hello activity diagram; +:This is defined on +several lines; +stop + +@enduml diff --git a/plantuml/parser/integration_test/activity_diagram/parser/swimlane/output.json b/plantuml/parser/integration_test/activity_diagram/parser/swimlane/output.json new file mode 100644 index 0000000..b38d015 --- /dev/null +++ b/plantuml/parser/integration_test/activity_diagram/parser/swimlane/output.json @@ -0,0 +1,82 @@ +{ + "swimlane.puml": { + "name": "swimlane", + "statements": [ + { + "Swimlane": { + "name": "Actor_For_green" + } + }, + { + "Start": null + }, + { + "IfStart": { + "condition": "color?", + "label": "green" + } + }, + { + "Action": { + "label": "action green" + } + }, + { + "Action": { + "label": "foo1" + } + }, + { + "Else": { + "label": "not green" + } + }, + { + "Swimlane": { + "name": "Actor_For_no_green" + } + }, + { + "Action": { + "label": "action not green" + } + }, + { + "Action": { + "label": "foo2" + } + }, + { + "EndIf": null + }, + { + "Swimlane": { + "name": "Next_Actor" + } + }, + { + "Action": { + "label": "foo3" + } + }, + { + "Action": { + "label": "foo4" + } + }, + { + "Swimlane": { + "name": "Final_Actor" + } + }, + { + "Action": { + "label": "foo5" + } + }, + { + "Stop": null + } + ] + } +} diff --git a/plantuml/parser/integration_test/activity_diagram/parser/swimlane/swimlane.puml b/plantuml/parser/integration_test/activity_diagram/parser/swimlane/swimlane.puml new file mode 100644 index 0000000..a6d2035 --- /dev/null +++ b/plantuml/parser/integration_test/activity_diagram/parser/swimlane/swimlane.puml @@ -0,0 +1,32 @@ +' ******************************************************************************* +' 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 swimlane + +|#green|Actor_For_green| +start +if (color?) is (green) then +#pink:**action green**; +:foo1; +else (not green) +|#lightgray|Actor_For_no_green| +#lightgray:~~action not green~~; +:foo2; +endif +|Next_Actor| +#LightCyan:foo3; +:foo4; +|Final_Actor| +#PaleGoldenRod:foo5; +stop + +@enduml diff --git a/plantuml/parser/integration_test/activity_diagram/parser/while/output.json b/plantuml/parser/integration_test/activity_diagram/parser/while/output.json new file mode 100644 index 0000000..28a3969 --- /dev/null +++ b/plantuml/parser/integration_test/activity_diagram/parser/while/output.json @@ -0,0 +1,48 @@ +{ + "while.puml": { + "name": "while", + "statements": [ + { + "Start": null + }, + { + "WhileStart": { + "condition": "check array length ?", + "label": "not 0" + } + }, + { + "Action": { + "label": "get elements" + } + }, + { + "IfStart": { + "condition": "elements is minus", + "label": "yes" + } + }, + { + "Control": { + "kind": "Break" + } + }, + { + "EndIf": null + }, + { + "EndWhile": { + "label": "is 0" + } + }, + { + "Action": { + "label": "delete array" + } + }, + { + "Stop": null + } + ] + } +} diff --git a/plantuml/parser/integration_test/activity_diagram/parser/while/while.puml b/plantuml/parser/integration_test/activity_diagram/parser/while/while.puml new file mode 100644 index 0000000..4dc881c --- /dev/null +++ b/plantuml/parser/integration_test/activity_diagram/parser/while/while.puml @@ -0,0 +1,27 @@ +' ******************************************************************************* +' 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 while + +start + +while (check array length ?) is (not 0) + :get elements; + if (elements is minus) then (yes) + break + endif +endwhile (is 0) +:delete array; + +end + +@enduml diff --git a/plantuml/parser/integration_test/src/test_error_view.rs b/plantuml/parser/integration_test/src/test_error_view.rs index f642772..d08599c 100644 --- a/plantuml/parser/integration_test/src/test_error_view.rs +++ b/plantuml/parser/integration_test/src/test_error_view.rs @@ -14,8 +14,8 @@ use std::collections::HashMap; use std::path::Path; use puml_parser::{ - BaseParseError, ClassError, IncludeExpandError, IncludeParseError, PreprocessError, - ProcedureExpandError, ProcedureParseError, + ActivityParserError, BaseParseError, ClassError, IncludeExpandError, IncludeParseError, + PreprocessError, ProcedureExpandError, ProcedureParseError, }; use puml_resolver::{ClassPumlResolverError, ElementResolverError}; @@ -197,6 +197,18 @@ impl ErrorView for ClassError { } } +impl ErrorView for ActivityParserError { + fn project(&self, base_dir: &Path) -> ProjectedError { + match self { + ActivityParserError::Base(e) => e.project(base_dir), + ActivityParserError::InvalidStatement(message) => { + let _ = base_dir; + ProjectedError::new("InvalidStatement").with_field("message", message.clone()) + } + } + } +} + impl ErrorView for ElementResolverError { fn project(&self, _base_dir: &Path) -> ProjectedError { match self { diff --git a/plantuml/parser/puml_parser/BUILD b/plantuml/parser/puml_parser/BUILD index 6528078..02c5c55 100644 --- a/plantuml/parser/puml_parser/BUILD +++ b/plantuml/parser/puml_parser/BUILD @@ -17,6 +17,7 @@ rust_library( srcs = ["src/lib.rs"], visibility = ["//plantuml/parser:__subpackages__"], deps = [ + ":activity_diagram", ":class_diagram", ":component_diagram", ":parser_core", @@ -25,6 +26,12 @@ rust_library( ], ) +alias( + name = "activity_diagram", + actual = "//plantuml/parser/puml_parser/src/activity_diagram:puml_parser_activity", + visibility = ["//plantuml/parser:__subpackages__"], +) + alias( name = "class_diagram", actual = "//plantuml/parser/puml_parser/src/class_diagram:puml_parser_class", diff --git a/plantuml/parser/puml_parser/src/activity_diagram/BUILD b/plantuml/parser/puml_parser/src/activity_diagram/BUILD new file mode 100644 index 0000000..a669a00 --- /dev/null +++ b/plantuml/parser/puml_parser/src/activity_diagram/BUILD @@ -0,0 +1,69 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* +load("@rules_rust//rust:defs.bzl", "rust_library", "rust_test") + +filegroup( + name = "puml_parser_activity_files", + srcs = [ + "src/activity_ast.rs", + "src/activity_parser.rs", + "src/creole.rs", + "src/lib.rs", + ], + visibility = ["//plantuml/parser:__subpackages__"], +) + +rust_library( + name = "puml_parser_activity", + srcs = [":puml_parser_activity_files"], + compile_data = [ + "//plantuml/parser/puml_parser/src/grammar:activity_grammar", + ], + crate_name = "activity_parser", + crate_root = "src/lib.rs", + proc_macro_deps = [ + "@crates//:pest_derive", + ], + visibility = ["//plantuml/parser:__subpackages__"], + deps = [ + "//plantuml/parser/puml_parser:parser_core", + "//plantuml/parser/puml_utils", + "@crates//:log", + "@crates//:pest", + "@crates//:serde", + "@crates//:thiserror", + ], +) + +rust_test( + name = "activity_unit_test", + crate = ":puml_parser_activity", + proc_macro_deps = [ + "@crates//:pest_derive", + ], +) + +rust_test( + name = "activity_integration_test", + srcs = ["test/integration_test.rs"], + args = [ + "--nocapture", + ], + data = ["//plantuml/parser/integration_test/activity_diagram:activity_diagram_files"], + deps = [ + ":puml_parser_activity", + "//plantuml/parser/integration_test:test_framework", + "//plantuml/parser/puml_parser:parser_core", + "//plantuml/parser/puml_utils", + ], +) diff --git a/plantuml/parser/puml_parser/src/activity_diagram/src/activity_ast.rs b/plantuml/parser/puml_parser/src/activity_diagram/src/activity_ast.rs new file mode 100644 index 0000000..8ab1cbd --- /dev/null +++ b/plantuml/parser/puml_parser/src/activity_diagram/src/activity_ast.rs @@ -0,0 +1,148 @@ +// ******************************************************************************* +// 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 serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct RawActivityDiagram { + pub name: Option, + pub statements: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum RawActivityStmt { + Action(ActionStmt), + Arrow(ArrowStmt), + Backward(BackwardStmt), + + Start(StartStmt), + Stop(StopStmt), + Control(ControlStmt), + + // ===== If ===== + IfStart(IfStartStmt), + Else(ElseStmt), + EndIf(EndIfStmt), + + // ===== While ===== + WhileStart(WhileStartStmt), + EndWhile(EndWhileStmt), + + // ===== Repeat ===== + RepeatStart(RepeatStartStmt), + RepeatWhile(RepeatWhileStmt), + + // ===== Fork ===== + ForkStart(ForkStartStmt), + ForkAgain(ForkAgainStmt), + ForkEnd(ForkEndStmt), + + // =====Swimlane ===== + Swimlane(SwimlaneStmt), +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ActionStmt { + pub label: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ArrowStmt { + pub syntax: String, + pub label: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct BackwardStmt { + pub label: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct StartStmt; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct StopStmt; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ControlStmt { + pub kind: ControlKind, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum ControlKind { + Break, + Kill, + Detach, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct IfStartStmt { + pub condition: String, + pub label: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ElseStmt { + pub label: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct EndIfStmt; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct WhileStartStmt { + pub condition: String, + pub label: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct EndWhileStmt { + pub label: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct RepeatStartStmt; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct RepeatWhileStmt { + pub condition: String, + pub label: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ForkStartStmt; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ForkAgainStmt; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ForkEndStmt { + pub kind: ForkEndKind, + pub modifier: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum ForkEndKind { + EndFork, + EndMerge, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum ForkModifier { + And, + Or, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct SwimlaneStmt { + pub name: String, +} diff --git a/plantuml/parser/puml_parser/src/activity_diagram/src/activity_parser.rs b/plantuml/parser/puml_parser/src/activity_diagram/src/activity_parser.rs new file mode 100644 index 0000000..7d36529 --- /dev/null +++ b/plantuml/parser/puml_parser/src/activity_diagram/src/activity_parser.rs @@ -0,0 +1,425 @@ +// ******************************************************************************* +// 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 log::debug; +use pest::Parser; +use std::path::PathBuf; +use std::rc::Rc; +use thiserror::Error; + +use crate::activity_ast::{ + ActionStmt, ArrowStmt, BackwardStmt, ControlKind, ControlStmt, ElseStmt, EndIfStmt, + EndWhileStmt, ForkAgainStmt, ForkEndKind, ForkEndStmt, ForkModifier, ForkStartStmt, + IfStartStmt, RepeatStartStmt, RepeatWhileStmt, StartStmt, StopStmt, SwimlaneStmt, + WhileStartStmt, +}; +use crate::creole::normalize_creole_text; +use crate::{RawActivityDiagram, RawActivityStmt}; +use parser_core::common_parser::{PlantUmlCommonParser, Rule}; +use parser_core::{ + format_parse_tree, pest_to_syntax_error, BaseParseError, DiagramParser, ErrorLocation, +}; +use puml_utils::LogLevel; + +#[derive(Debug, Error)] +pub enum ActivityParserError { + #[error(transparent)] + Base(#[from] BaseParseError), + #[error("invalid activity statement: {0}")] + InvalidStatement(String), +} + +impl ErrorLocation for ActivityParserError { + fn error_location(&self) -> Option<(usize, usize)> { + match self { + Self::Base(base) => base.error_location(), + _ => None, + } + } +} + +pub struct PumlActivityParser; + +impl PumlActivityParser { + fn parse_startuml(pair: pest::iterators::Pair) -> Option { + pair.into_inner() + .find(|inner| inner.as_rule() == Rule::puml_name) + .map(|inner| inner.as_str().trim().to_string()) + } + + fn parse_statement( + pair: pest::iterators::Pair, + ) -> Result, ActivityParserError> { + let statement = match pair.as_rule() { + Rule::action_stmt => RawActivityStmt::Action(Self::parse_action_stmt(pair)?), + Rule::arrow_stmt => RawActivityStmt::Arrow(Self::parse_arrow_stmt(pair)?), + Rule::backward_stmt => RawActivityStmt::Backward(Self::parse_backward_stmt(pair)?), + Rule::control_stmt => RawActivityStmt::Control(Self::parse_control_stmt(pair)?), + Rule::start_stmt => RawActivityStmt::Start(Self::parse_start_stmt(pair)?), + Rule::stop_stmt => RawActivityStmt::Stop(Self::parse_stop_stmt(pair)?), + Rule::if_start_stmt => RawActivityStmt::IfStart(Self::parse_if_start_stmt(pair)?), + Rule::else_stmt => RawActivityStmt::Else(Self::parse_else_stmt(pair)?), + Rule::endif_stmt => RawActivityStmt::EndIf(Self::parse_endif_stmt(pair)?), + Rule::while_start_stmt => { + RawActivityStmt::WhileStart(Self::parse_while_start_stmt(pair)?) + } + Rule::endwhile_stmt => RawActivityStmt::EndWhile(Self::parse_endwhile_stmt(pair)?), + Rule::repeat_start_stmt => { + RawActivityStmt::RepeatStart(Self::parse_repeat_start_stmt(pair)?) + } + Rule::repeat_while_stmt => { + RawActivityStmt::RepeatWhile(Self::parse_repeat_while_stmt(pair)?) + } + Rule::fork_start_stmt => RawActivityStmt::ForkStart(Self::parse_fork_start_stmt(pair)?), + Rule::fork_again_stmt => RawActivityStmt::ForkAgain(Self::parse_fork_again_stmt(pair)?), + Rule::fork_end_stmt => RawActivityStmt::ForkEnd(Self::parse_fork_end_stmt(pair)?), + Rule::swimlane_stmt => RawActivityStmt::Swimlane(Self::parse_swimlane_stmt(pair)?), + _ => return Ok(vec![]), + }; + + Ok(vec![statement]) + } + + fn parse_arrow_stmt( + pair: pest::iterators::Pair, + ) -> Result { + let mut syntax = None; + let mut label = None; + + for inner in pair.into_inner() { + match inner.as_rule() { + Rule::connection_arrow => { + syntax = Some(inner.as_str().trim().to_string()); + } + Rule::arrow_text => { + label = Some(normalize_creole_text(inner.as_str().trim())); + } + _ => {} + } + } + + Ok(ArrowStmt { + syntax: syntax.ok_or_else(|| { + ActivityParserError::InvalidStatement("missing arrow syntax".to_string()) + })?, + label, + }) + } + + fn parse_action_label( + pair: pest::iterators::Pair, + statement_kind: &str, + ) -> Result { + pair.into_inner() + .find(|inner| matches!(inner.as_rule(), Rule::action_text | Rule::action_line_text)) + .map(|inner| normalize_creole_text(inner.as_str().trim())) + .ok_or_else(|| { + ActivityParserError::InvalidStatement(format!("missing {} label", statement_kind,)) + }) + } + + fn parse_action_stmt( + pair: pest::iterators::Pair, + ) -> Result { + let label = Self::parse_action_label(pair, "action")?; + + Ok(ActionStmt { label }) + } + + fn parse_backward_stmt( + pair: pest::iterators::Pair, + ) -> Result { + let label = Self::parse_action_label(pair, "backward")?; + + Ok(BackwardStmt { label }) + } + + fn parse_start_stmt( + _pair: pest::iterators::Pair, + ) -> Result { + Ok(StartStmt) + } + + fn parse_stop_stmt( + _pair: pest::iterators::Pair, + ) -> Result { + Ok(StopStmt) + } + + fn parse_control_stmt( + pair: pest::iterators::Pair, + ) -> Result { + let kind = match pair.as_str().trim() { + "break" => ControlKind::Break, + "kill" => ControlKind::Kill, + "detach" => ControlKind::Detach, + _ => { + return Err(ActivityParserError::InvalidStatement(format!( + "invalid control kind: {}", + pair.as_str().trim(), + ))) + } + }; + + Ok(ControlStmt { kind }) + } + + fn parse_if_start_stmt( + pair: pest::iterators::Pair, + ) -> Result { + let mut condition = None; + let mut label = None; + + for inner in pair.into_inner() { + match inner.as_rule() { + Rule::condition_text if condition.is_none() => { + condition = Some(normalize_creole_text(inner.as_str().trim())); + } + Rule::if_pre_label => { + label = inner + .into_inner() + .find(|nested| nested.as_rule() == Rule::condition_text) + .map(|nested| normalize_creole_text(nested.as_str().trim())); + } + Rule::then_label => { + label = inner + .into_inner() + .find(|nested| nested.as_rule() == Rule::condition_text) + .map(|nested| normalize_creole_text(nested.as_str().trim())); + } + _ => {} + } + } + + Ok(IfStartStmt { + condition: condition.ok_or_else(|| { + ActivityParserError::InvalidStatement("missing if condition".to_string()) + })?, + label, + }) + } + + fn parse_else_stmt(pair: pest::iterators::Pair) -> Result { + let label = pair + .into_inner() + .find(|inner| inner.as_rule() == Rule::condition_text) + .map(|inner| normalize_creole_text(inner.as_str().trim())); + + Ok(ElseStmt { label }) + } + + fn parse_endif_stmt( + _pair: pest::iterators::Pair, + ) -> Result { + Ok(EndIfStmt) + } + + fn parse_while_start_stmt( + pair: pest::iterators::Pair, + ) -> Result { + let mut condition = None; + let mut label = None; + + for inner in pair.into_inner() { + match inner.as_rule() { + Rule::condition_text if condition.is_none() => { + condition = Some(normalize_creole_text(inner.as_str().trim())); + } + Rule::while_label => { + label = inner + .into_inner() + .find(|nested| nested.as_rule() == Rule::condition_text) + .map(|nested| normalize_creole_text(nested.as_str().trim())); + } + _ => {} + } + } + + Ok(WhileStartStmt { + condition: condition.ok_or_else(|| { + ActivityParserError::InvalidStatement("missing while condition".to_string()) + })?, + label, + }) + } + + fn parse_endwhile_stmt( + pair: pest::iterators::Pair, + ) -> Result { + let label = pair + .into_inner() + .find(|inner| inner.as_rule() == Rule::endwhile_label) + .and_then(|inner| { + inner + .into_inner() + .find(|nested| nested.as_rule() == Rule::condition_text) + }) + .map(|inner| normalize_creole_text(inner.as_str().trim())); + + Ok(EndWhileStmt { label }) + } + + fn parse_repeat_start_stmt( + _pair: pest::iterators::Pair, + ) -> Result { + Ok(RepeatStartStmt) + } + + fn parse_repeat_while_stmt( + pair: pest::iterators::Pair, + ) -> Result { + let mut condition = None; + let mut label = None; + + for inner in pair.into_inner() { + match inner.as_rule() { + Rule::condition_text if condition.is_none() => { + condition = Some(normalize_creole_text(inner.as_str().trim())); + } + Rule::repeat_label => { + label = inner + .into_inner() + .find(|nested| nested.as_rule() == Rule::condition_text) + .map(|nested| normalize_creole_text(nested.as_str().trim())); + } + _ => {} + } + } + + Ok(RepeatWhileStmt { + condition: condition.ok_or_else(|| { + ActivityParserError::InvalidStatement("missing repeat while condition".to_string()) + })?, + label, + }) + } + + fn parse_fork_start_stmt( + _pair: pest::iterators::Pair, + ) -> Result { + Ok(ForkStartStmt) + } + + fn parse_fork_again_stmt( + _pair: pest::iterators::Pair, + ) -> Result { + Ok(ForkAgainStmt) + } + + fn parse_fork_end_stmt( + pair: pest::iterators::Pair, + ) -> Result { + let kind = if pair.as_str().contains("merge") { + ForkEndKind::EndMerge + } else { + ForkEndKind::EndFork + }; + let mut modifier = None; + + for inner in pair.into_inner() { + if inner.as_rule() == Rule::fork_modifier { + let text = inner.as_str(); + modifier = if text.contains("and") { + Some(ForkModifier::And) + } else if text.contains("or") { + Some(ForkModifier::Or) + } else { + return Err(ActivityParserError::InvalidStatement(format!( + "invalid fork modifier: {}", + text, + ))); + }; + } + } + + Ok(ForkEndStmt { kind, modifier }) + } + + fn parse_swimlane_stmt( + pair: pest::iterators::Pair, + ) -> Result { + let name = pair + .into_inner() + .find(|inner| inner.as_rule() == Rule::swimlane_text) + .map(|inner| normalize_creole_text(inner.as_str().trim())) + .ok_or_else(|| { + ActivityParserError::InvalidStatement("missing swimlane name".to_string()) + })?; + + Ok(SwimlaneStmt { name }) + } +} + +impl DiagramParser for PumlActivityParser { + type Output = RawActivityDiagram; + type Error = ActivityParserError; + + fn parse_file( + &mut self, + path: &Rc, + content: &str, + log_level: LogLevel, + ) -> Result { + let pairs = PlantUmlCommonParser::parse(Rule::activity_diagram, content) + .map_err(|error| pest_to_syntax_error(error, path.as_ref().clone(), content))?; + + #[cfg(not(coverage))] + if matches!(log_level, LogLevel::Debug | LogLevel::Trace) { + let mut tree_output = String::new(); + format_parse_tree(pairs.clone(), 0, &mut tree_output); + + debug!( + "\n=== Parse Tree for {} ===\n{}=== End Parse Tree ===", + path.display(), + tree_output + ); + } + + let mut document = RawActivityDiagram { + name: None, + statements: Vec::new(), + }; + + for pair in pairs { + for inner_pair in pair.into_inner() { + match inner_pair.as_rule() { + Rule::startuml => { + document.name = Self::parse_startuml(inner_pair); + } + Rule::arrow_stmt + | Rule::backward_stmt + | Rule::action_stmt + | Rule::control_stmt + | Rule::start_stmt + | Rule::stop_stmt + | Rule::if_start_stmt + | Rule::else_stmt + | Rule::endif_stmt + | Rule::while_start_stmt + | Rule::endwhile_stmt + | Rule::repeat_start_stmt + | Rule::repeat_while_stmt + | Rule::fork_start_stmt + | Rule::fork_again_stmt + | Rule::fork_end_stmt + | Rule::swimlane_stmt => { + let mut statements = Self::parse_statement(inner_pair)?; + document.statements.append(&mut statements); + } + _ => {} + } + } + } + + Ok(document) + } +} diff --git a/plantuml/parser/puml_parser/src/activity_diagram/src/creole.rs b/plantuml/parser/puml_parser/src/activity_diagram/src/creole.rs new file mode 100644 index 0000000..1363725 --- /dev/null +++ b/plantuml/parser/puml_parser/src/activity_diagram/src/creole.rs @@ -0,0 +1,304 @@ +// ******************************************************************************* +// 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 +// ******************************************************************************* + +pub(crate) fn normalize_creole_text(text: &str) -> String { + let mut normalized_lines = Vec::new(); + + for line in text.lines() { + normalized_lines.push(normalize_creole_line(line)); + } + + while normalized_lines.last().is_some_and(|line| line.is_empty()) { + normalized_lines.pop(); + } + + normalized_lines.join("\n") +} + +fn normalize_creole_line(line: &str) -> String { + let trimmed = line.trim(); + + if trimmed.is_empty() || is_horizontal_rule(trimmed) { + return String::new(); + } + + if let Some(heading) = strip_heading_markup(trimmed) { + return normalize_creole_inline(heading.trim()); + } + + if looks_like_table_row(trimmed) { + return normalize_table_row(trimmed); + } + + let content = strip_list_marker(trimmed).unwrap_or(trimmed); + + normalize_creole_inline(content) +} + +fn normalize_creole_inline(text: &str) -> String { + const INLINE_MARKERS: [&str; 8] = ["**", "//", "__", "~~", "\"\"", "##", "^^", ",,"]; + + let mut normalized = String::new(); + let mut active_marker: Option<&'static str> = None; + let mut index = 0; + + while index < text.len() { + let remaining = &text[index..]; + + if let Some(content) = extract_bracketed_content(remaining, "[[", "]]") { + normalized.push_str(&normalize_link_content(&content)); + index += content.len() + 4; + continue; + } + + if let Some(content) = extract_bracketed_content(remaining, "{{", "}}") { + normalized.push_str(&normalize_image_content(&content)); + index += content.len() + 4; + continue; + } + + if let Some(tag_len) = creole_tag_length(remaining) { + index += tag_len; + continue; + } + + let mut consumed_marker = false; + for marker in INLINE_MARKERS { + if remaining.starts_with(marker) { + match active_marker { + Some(active) if active == marker => { + active_marker = None; + index += marker.len(); + consumed_marker = true; + } + None if should_open_inline_marker(text, index, marker) => { + active_marker = Some(marker); + index += marker.len(); + consumed_marker = true; + } + _ => {} + } + if consumed_marker { + break; + } + } + } + + if consumed_marker { + continue; + } + + if let Some(next) = remaining.strip_prefix('~') { + if let Some(ch) = next.chars().next() { + normalized.push(ch); + index += '~'.len_utf8() + ch.len_utf8(); + } else { + index += '~'.len_utf8(); + } + continue; + } + + let ch = remaining.chars().next().expect("remaining is non-empty"); + normalized.push(ch); + index += ch.len_utf8(); + } + + collapse_internal_whitespace(&normalized) +} + +fn collapse_internal_whitespace(text: &str) -> String { + text.split_whitespace().collect::>().join(" ") +} + +fn should_open_inline_marker(text: &str, index: usize, marker: &str) -> bool { + let after_index = index + marker.len(); + let before = text[..index].chars().next_back(); + let after = text[after_index..].chars().next(); + + if after.is_none() || after.is_some_and(char::is_whitespace) { + return false; + } + + if marker == "//" && before == Some(':') { + return false; + } + + text[after_index..].contains(marker) +} + +fn extract_bracketed_content(text: &str, open: &str, close: &str) -> Option { + if !text.starts_with(open) { + return None; + } + + let content = &text[open.len()..]; + let end = content.find(close)?; + + Some(content[..end].to_string()) +} + +fn normalize_link_content(content: &str) -> String { + let trimmed = content.trim(); + let (target, label) = split_first_unescaped_whitespace(trimmed); + + if let Some(label) = label { + return normalize_creole_inline(label.trim()); + } + + strip_tooltip(target.trim()).to_string() +} + +fn normalize_image_content(content: &str) -> String { + let trimmed = content.trim(); + + if let Some((_, caption)) = trimmed.split_once('|') { + return normalize_creole_inline(caption.trim()); + } + + String::new() +} + +fn split_first_unescaped_whitespace(text: &str) -> (&str, Option<&str>) { + let mut brace_depth = 0; + + for (index, ch) in text.char_indices() { + match ch { + '{' => brace_depth += 1, + '}' if brace_depth > 0 => brace_depth -= 1, + _ if brace_depth == 0 && ch.is_whitespace() => { + let tail = text[index..].trim(); + return (&text[..index], (!tail.is_empty()).then_some(tail)); + } + _ => {} + } + } + + (text, None) +} + +fn strip_tooltip(text: &str) -> &str { + match text.find('{') { + Some(index) => &text[..index], + None => text, + } +} + +fn creole_tag_length(text: &str) -> Option { + if !text.starts_with('<') { + return None; + } + + let end = text.find('>')?; + let tag = &text[1..end].trim().to_ascii_lowercase(); + + let known_tags = [ + "b", "/b", "i", "/i", "u", "/u", "s", "/s", "w", "/w", "img", "/img", "font", "/font", + ]; + + if known_tags.contains(&tag.as_str()) + || tag.starts_with("color:") + || tag.starts_with("back:") + || tag.starts_with("size:") + { + Some(end + 1) + } else { + None + } +} + +fn looks_like_table_row(text: &str) -> bool { + text.starts_with('|') && text.ends_with('|') && text.len() >= 2 +} + +fn normalize_table_row(text: &str) -> String { + text.trim_matches('|') + .split('|') + .map(|cell| cell.trim().trim_start_matches('=').trim()) + .filter(|cell| !cell.is_empty()) + .map(normalize_creole_inline) + .collect::>() + .join(" | ") +} + +fn strip_heading_markup(text: &str) -> Option<&str> { + let leading = text.chars().take_while(|ch| *ch == '=').count(); + let trailing = text.chars().rev().take_while(|ch| *ch == '=').count(); + + if leading == 0 || trailing == 0 || text.len() <= leading + trailing { + return None; + } + + Some(&text[leading..text.len() - trailing]) +} + +fn strip_list_marker(text: &str) -> Option<&str> { + let marker_len = text + .chars() + .take_while(|ch| matches!(ch, '*' | '#')) + .count(); + + if marker_len == 0 { + return None; + } + + if !text[marker_len..] + .chars() + .next() + .is_some_and(char::is_whitespace) + { + return None; + } + + let remainder = text[marker_len..].trim_start(); + Some(remainder) +} + +fn is_horizontal_rule(text: &str) -> bool { + text.len() >= 4 && text.chars().all(|ch| ch == '-') +} + +#[cfg(test)] +mod tests { + use super::normalize_creole_text; + + #[test] + fn normalize_creole_inline_styles_to_plain_text() { + assert_eq!( + normalize_creole_text("Hello \"\"code\"\" and **bold** with __underline__ ~~wave~~"), + "Hello code and bold with underline wave" + ); + } + + #[test] + fn normalize_creole_links_images_and_escapes() { + assert_eq!( + normalize_creole_text( + "[[https://example.com/docs{tip} external docs]] {{img.png|preview}} ~*literal" + ), + "external docs preview *literal" + ); + } + + #[test] + fn normalize_creole_block_syntax_to_plain_text() { + assert_eq!( + normalize_creole_text("= Heading =\n* first item\n|=A|B|\n----\n"), + "Heading\nfirst item\nA | B" + ); + } + + #[test] + fn normalize_full_line_bold_text_without_list_stripping() { + assert_eq!(normalize_creole_text("**action green**"), "action green"); + } +} diff --git a/plantuml/parser/puml_parser/src/activity_diagram/src/lib.rs b/plantuml/parser/puml_parser/src/activity_diagram/src/lib.rs new file mode 100644 index 0000000..d530029 --- /dev/null +++ b/plantuml/parser/puml_parser/src/activity_diagram/src/lib.rs @@ -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 +// +// +// SPDX-License-Identifier: Apache-2.0 +// ******************************************************************************* + +mod activity_ast; +mod activity_parser; +mod creole; + +pub use activity_ast::{RawActivityDiagram, RawActivityStmt}; +pub use activity_parser::{ActivityParserError, PumlActivityParser}; diff --git a/plantuml/parser/puml_parser/src/activity_diagram/test/integration_test.rs b/plantuml/parser/puml_parser/src/activity_diagram/test/integration_test.rs new file mode 100644 index 0000000..dcb0363 --- /dev/null +++ b/plantuml/parser/puml_parser/src/activity_diagram/test/integration_test.rs @@ -0,0 +1,110 @@ +// ******************************************************************************* +// 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::{HashMap, HashSet}; +use std::fs; +use std::path::PathBuf; +use std::rc::Rc; + +use activity_parser::{ActivityParserError, PumlActivityParser, RawActivityDiagram}; +use parser_core::{BaseParseError, DiagramParser}; +use puml_utils::LogLevel; +use test_framework::{run_case, DefaultExpectationChecker, DiagramProcessor}; + +struct ActivityRunner; + +impl DiagramProcessor for ActivityRunner { + type Output = RawActivityDiagram; + type Error = ActivityParserError; + + fn run( + &self, + files: &HashSet>, + ) -> Result, RawActivityDiagram>, ActivityParserError> { + let mut results: HashMap, RawActivityDiagram> = HashMap::new(); + let mut parser = PumlActivityParser; + + for puml_path in files { + let uml_content = fs::read_to_string(&**puml_path).map_err(|error| { + ActivityParserError::Base(BaseParseError::IoError { + path: puml_path.as_ref().to_path_buf(), + error: Box::new(error), + }) + })?; + + let activity_ast = parser.parse_file(puml_path, ¨_content, LogLevel::Error)?; + + results.insert(Rc::clone(puml_path), activity_ast); + } + + Ok(results) + } +} + +fn run_activity_diagram_parser_case(case_name: &str) { + run_case( + "integration_test/activity_diagram/parser", + case_name, + ActivityRunner, + DefaultExpectationChecker, + ); +} + +#[test] +fn test_action_simple() { + run_activity_diagram_parser_case("action_simple"); +} + +#[test] +fn test_action_separated() { + run_activity_diagram_parser_case("action_separated"); +} + +#[test] +fn test_start_stop() { + run_activity_diagram_parser_case("start_stop"); +} + +#[test] +fn test_if() { + run_activity_diagram_parser_case("if"); +} + +#[test] +fn test_color() { + run_activity_diagram_parser_case("color"); +} + +#[test] +fn test_arrows() { + run_activity_diagram_parser_case("arrows"); +} + +#[test] +fn test_repeat_while() { + run_activity_diagram_parser_case("repeat_while"); +} + +#[test] +fn test_fork() { + run_activity_diagram_parser_case("fork"); +} + +#[test] +fn test_swimlane() { + run_activity_diagram_parser_case("swimlane"); +} + +#[test] +fn test_while() { + run_activity_diagram_parser_case("while"); +} diff --git a/plantuml/parser/puml_parser/src/grammar/BUILD b/plantuml/parser/puml_parser/src/grammar/BUILD index a214557..9005058 100644 --- a/plantuml/parser/puml_parser/src/grammar/BUILD +++ b/plantuml/parser/puml_parser/src/grammar/BUILD @@ -13,6 +13,7 @@ filegroup( name = "grammar_files", srcs = [ + ":activity_grammar", ":class_grammar", ":common_grammar", ":component_grammar", @@ -70,3 +71,11 @@ filegroup( ], visibility = ["//visibility:public"], ) + +filegroup( + name = "activity_grammar", + srcs = [ + "activity.pest", + ], + visibility = ["//visibility:public"], +) diff --git a/plantuml/parser/puml_parser/src/grammar/activity.pest b/plantuml/parser/puml_parser/src/grammar/activity.pest new file mode 100644 index 0000000..1bdbe30 --- /dev/null +++ b/plantuml/parser/puml_parser/src/grammar/activity.pest @@ -0,0 +1,192 @@ +// ******************************************************************************* +// 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 +// ******************************************************************************* +activity_diagram = { + SOI + ~ empty_line* + ~ startuml + ~ (statement | empty_line)* + ~ activity_enduml + ~ EOI +} + +activity_enduml = { + "@enduml" + ~ (WHITESPACE* ~ EOL)* + ~ WHITESPACE* +} + +statement = _{ + // fork + fork_again_stmt + | fork_end_stmt + | fork_start_stmt + + // repeat + | repeat_while_stmt + | repeat_start_stmt + + // if + | else_stmt + | endif_stmt + | if_start_stmt + + // while + | endwhile_stmt + | while_start_stmt + + // base + | note_declaration + | backward_stmt + | arrow_stmt + | action_stmt + | control_stmt + | swimlane_stmt + | start_stmt + | stop_stmt +} + +// Basic Wheels +action_text = @{ + (!(";") ~ ANY)+ +} + +action_line_text = @{ + (!NEWLINE ~ ANY)+ +} + +arrow_text = @{ + arrow_text_first_line + ~ (!(";") ~ ANY)* +} + +arrow_text_first_line = { + (!(";" | NEWLINE) ~ ANY)+ +} + +condition_text = @{ + (condition_nested_group | condition_char)+ +} + +condition_nested_group = { + "(" + ~ (condition_nested_group | condition_char)* + ~ ")" +} + +condition_char = { + !("(" | ")" | NEWLINE) + ~ ANY +} + +swimlane_text = @{ + (!("|" | NEWLINE) ~ ANY)+ +} + +keyword_boundary = _{ !ASCII_ALPHANUMERIC } + +control_stmt = { "break" | "kill" | "detach" } + +// Action +action_stmt = { BASIC_COLOR? ~ ":" ~ action_text ~ ";" | "-" ~ action_line_text } +backward_stmt = { "backward" ~ BASIC_COLOR? ~ ":" ~ action_text ~ ";" } +arrow_stmt = { + connection_arrow + ~ ( + INLINE_WS* ~ arrow_text ~ ";" + | &(WHITESPACE* ~ NEWLINE) + | &activity_enduml + ) +} + +// If +if_start_stmt = { + "if" + ~ "(" ~ condition_text ~ ")" + ~ (if_pre_label ~ "then" | "then" ~ then_label?) +} +if_pre_label = { "is" ~ "(" ~ condition_text ~ ")" } +then_label = { "(" ~ condition_text ~ ")" } + +else_stmt = { + "else" + ~ ("(" ~ condition_text ~ ")")? +} + +endif_stmt = { "endif" } + +// While +while_start_stmt = { + "while" + ~ "(" ~ condition_text ~ ")" + ~ while_label? +} + +while_label = { + "is" + ~ "(" ~ condition_text ~ ")" +} + +endwhile_stmt = { + "endwhile" + ~ endwhile_label? +} + +endwhile_label = { "(" ~ condition_text ~ ")" } + +// Repeat While +repeat_start_stmt = { "repeat" } + +repeat_while_stmt = { + "repeat while" + ~ "(" ~ condition_text ~ ")" + ~ repeat_label? +} + +repeat_label = { + "is" + ~ "(" ~ condition_text ~ ")" +} + +// Fork +fork_start_stmt = { + "fork" + ~ keyword_boundary +} +fork_again_stmt = { "fork again" } + +end_fork_kw = { "end fork" } + +end_merge_kw = { "end merge" } + +fork_end_keyword = { end_fork_kw | end_merge_kw } + +fork_end_stmt = { + fork_end_keyword + ~ fork_modifier? +} + +fork_modifier = ${ + "{" + ~ ("and" | "or") + ~ "}" +} + +// Swimlane +swimlane_stmt = { "|" ~ (BASIC_COLOR ~ "|")? ~ swimlane_text ~ "|" } + +// Start/Stop +start_stmt = { "start" } +stop_stmt = { + "stop" + | "end" +} diff --git a/plantuml/parser/puml_parser/src/lib.rs b/plantuml/parser/puml_parser/src/lib.rs index cee8f4c..efe5955 100644 --- a/plantuml/parser/puml_parser/src/lib.rs +++ b/plantuml/parser/puml_parser/src/lib.rs @@ -12,6 +12,9 @@ // ******************************************************************************* // Re-export commonly used items that don't have name conflicts +pub use activity_parser::{ + ActivityParserError, PumlActivityParser, RawActivityDiagram, RawActivityStmt, +}; pub use class_parser::{ClassError, ClassUmlFile, PumlClassParser}; pub use component_parser::{CompPumlDocument, ComponentError, Element, PumlComponentParser}; pub use parser_core::{ diff --git a/plantuml/parser/puml_parser/src/parser_core/src/common_parser.rs b/plantuml/parser/puml_parser/src/parser_core/src/common_parser.rs index a0ad31e..64d58ab 100644 --- a/plantuml/parser/puml_parser/src/parser_core/src/common_parser.rs +++ b/plantuml/parser/puml_parser/src/parser_core/src/common_parser.rs @@ -16,6 +16,7 @@ use crate::common_ast::*; #[derive(Parser)] #[grammar = "../grammar/common.pest"] +#[grammar = "../grammar/activity.pest"] #[grammar = "../grammar/class.pest"] #[grammar = "../grammar/component.pest"] #[grammar = "../grammar/sequence.pest"]