From 1d23f37f96b91a54433e6f6df28c0ea8869cad6f Mon Sep 17 00:00:00 2001 From: xiaobao Date: Tue, 12 May 2026 13:54:56 +0800 Subject: [PATCH 1/4] activity diagram parser ast & pest --- .../activity_diagram/activity_diagram.puml | 47 +++++++ plantuml/parser/puml_parser/BUILD | 7 + .../puml_parser/src/activity_diagram/BUILD | 30 +++++ .../src/activity_diagram/src/activity_ast.rs | 118 +++++++++++++++++ .../src/activity_diagram/src/lib.rs | 16 +++ plantuml/parser/puml_parser/src/grammar/BUILD | 9 ++ .../puml_parser/src/grammar/activity.pest | 124 ++++++++++++++++++ plantuml/parser/puml_parser/src/lib.rs | 1 + 8 files changed, 352 insertions(+) create mode 100644 plantuml/parser/integration_test/activity_diagram/activity_diagram.puml create mode 100644 plantuml/parser/puml_parser/src/activity_diagram/BUILD create mode 100644 plantuml/parser/puml_parser/src/activity_diagram/src/activity_ast.rs create mode 100644 plantuml/parser/puml_parser/src/activity_diagram/src/lib.rs create mode 100644 plantuml/parser/puml_parser/src/grammar/activity.pest 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 00000000..00591a17 --- /dev/null +++ b/plantuml/parser/integration_test/activity_diagram/activity_diagram.puml @@ -0,0 +1,47 @@ +@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/puml_parser/BUILD b/plantuml/parser/puml_parser/BUILD index 6528078a..02c5c553 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 00000000..de8af0d4 --- /dev/null +++ b/plantuml/parser/puml_parser/src/activity_diagram/BUILD @@ -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 +# ******************************************************************************* +load("@rules_rust//rust:defs.bzl", "rust_library") + +filegroup( + name = "puml_parser_activity_files", + srcs = [ + "src/activity_ast.rs", + "src/lib.rs", + ], + visibility = ["//plantuml/parser:__subpackages__"], +) + +rust_library( + name = "puml_parser_activity", + srcs = [":puml_parser_activity_files"], + crate_name = "activity_parser", + crate_root = "src/lib.rs", + visibility = ["//plantuml/parser:__subpackages__"], +) 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 00000000..6467dcd9 --- /dev/null +++ b/plantuml/parser/puml_parser/src/activity_diagram/src/activity_ast.rs @@ -0,0 +1,118 @@ +// ******************************************************************************* +// 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), + + Start(StartStmt), + Stop(StopStmt), + + // ===== 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 StartStmt; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct StopStmt; + +#[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, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct EndWhileStmt; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct RepeatStartStmt; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct RepeatWhileStmt { + pub condition: String, +} + +#[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/lib.rs b/plantuml/parser/puml_parser/src/activity_diagram/src/lib.rs new file mode 100644 index 00000000..fe9bc72b --- /dev/null +++ b/plantuml/parser/puml_parser/src/activity_diagram/src/lib.rs @@ -0,0 +1,16 @@ +// ***************************************************************************** +// 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; + +pub use activity_ast::{RawActivityDiagram, RawActivityStmt}; diff --git a/plantuml/parser/puml_parser/src/grammar/BUILD b/plantuml/parser/puml_parser/src/grammar/BUILD index a2145573..90050585 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 00000000..118c5f77 --- /dev/null +++ b/plantuml/parser/puml_parser/src/grammar/activity.pest @@ -0,0 +1,124 @@ +// ******************************************************************************* +// 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)* + ~ enduml + ~ EOI +} + +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 + | action_stmt + | swimlane_stmt + | start_stmt + | stop_stmt +} + +// Basic Wheels +action_text = @{ + (!(";" | NEWLINE) ~ ANY)+ +} + +condition_text = @{ + (!(")" | NEWLINE) ~ ANY)+ +} + +swimlane_text = @{ + (!("|" | NEWLINE) ~ ANY)+ +} + +keyword_boundary = _{ !ASCII_ALPHANUMERIC } + +// Action +action_stmt = { ":" ~ action_text ~ ";" } + +// If +if_start_stmt = { + "if" + ~ "(" ~ condition_text ~ ")" + ~ "then" + ~ then_label? +} +then_label = { "(" ~ condition_text ~ ")" } + +else_stmt = { + "else" + ~ ("(" ~ condition_text ~ ")")? +} + +endif_stmt = { "endif" } + +// While +while_start_stmt = { + "while" + ~ "(" ~ condition_text ~ ")" +} + +endwhile_stmt = { "endwhile" } + +// Repeat While +repeat_start_stmt = { "repeat" } + +repeat_while_stmt = { + "repeat while" + ~ "(" ~ condition_text ~ ")" +} + +// Fork +fork_start_stmt = { + "fork" + ~ keyword_boundary +} +fork_again_stmt = { "fork again" } + +fork_end_stmt = { + ("end fork" | "end merge") + ~ fork_modifier? +} + +fork_modifier = { + "{" + ~ ("and" | "or") + ~ "}" +} + +// Swimlane +swimlane_stmt = { "|" ~ 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 cee8f4cb..69340005 100644 --- a/plantuml/parser/puml_parser/src/lib.rs +++ b/plantuml/parser/puml_parser/src/lib.rs @@ -12,6 +12,7 @@ // ******************************************************************************* // Re-export commonly used items that don't have name conflicts +pub use activity_parser::{RawActivityDiagram, RawActivityStmt}; pub use class_parser::{ClassError, ClassUmlFile, PumlClassParser}; pub use component_parser::{CompPumlDocument, ComponentError, Element, PumlComponentParser}; pub use parser_core::{ From e1410d095a721a50ac58f535e60c67475235e228 Mon Sep 17 00:00:00 2001 From: xiaobao Date: Tue, 12 May 2026 16:29:55 +0800 Subject: [PATCH 2/4] activity parser --- .../integration_test/activity_diagram/BUILD | 21 + .../activity_diagram/activity_diagram.puml | 12 + .../action_separated/action_separated.puml | 19 + .../parser/action_separated/output.json | 22 + .../parser/action_simple/action_simple.puml | 19 + .../parser/action_simple/output.json | 17 + .../parser/arrows/arrows.puml | 31 ++ .../parser/arrows/output.json | 76 ++++ .../activity_diagram/parser/color/color.puml | 17 + .../activity_diagram/parser/color/output.json | 12 + .../activity_diagram/parser/fork/fork.puml | 28 ++ .../activity_diagram/parser/fork/output.json | 54 +++ .../activity_diagram/parser/if/if.puml | 30 ++ .../activity_diagram/parser/if/output.json | 66 +++ .../parser/repeat_while/output.json | 37 ++ .../parser/repeat_while/repeat_while.puml | 26 ++ .../parser/start_stop/output.json | 23 + .../parser/start_stop/start_stop.puml | 21 + .../parser/swimlane/output.json | 82 ++++ .../parser/swimlane/swimlane.puml | 32 ++ .../activity_diagram/parser/while/output.json | 48 ++ .../activity_diagram/parser/while/while.puml | 27 ++ .../integration_test/src/test_error_view.rs | 14 +- .../puml_parser/src/activity_diagram/BUILD | 41 +- .../src/activity_diagram/src/activity_ast.rs | 32 +- .../activity_diagram/src/activity_parser.rs | 409 ++++++++++++++++++ .../src/activity_diagram/src/creole.rs | 305 +++++++++++++ .../src/activity_diagram/src/lib.rs | 7 +- .../activity_diagram/test/integration_test.rs | 110 +++++ .../puml_parser/src/grammar/activity.pest | 76 +++- plantuml/parser/puml_parser/src/lib.rs | 2 +- .../src/parser_core/src/common_parser.rs | 1 + 32 files changed, 1704 insertions(+), 13 deletions(-) create mode 100644 plantuml/parser/integration_test/activity_diagram/BUILD create mode 100644 plantuml/parser/integration_test/activity_diagram/parser/action_separated/action_separated.puml create mode 100644 plantuml/parser/integration_test/activity_diagram/parser/action_separated/output.json create mode 100644 plantuml/parser/integration_test/activity_diagram/parser/action_simple/action_simple.puml create mode 100644 plantuml/parser/integration_test/activity_diagram/parser/action_simple/output.json create mode 100644 plantuml/parser/integration_test/activity_diagram/parser/arrows/arrows.puml create mode 100644 plantuml/parser/integration_test/activity_diagram/parser/arrows/output.json create mode 100644 plantuml/parser/integration_test/activity_diagram/parser/color/color.puml create mode 100644 plantuml/parser/integration_test/activity_diagram/parser/color/output.json create mode 100644 plantuml/parser/integration_test/activity_diagram/parser/fork/fork.puml create mode 100644 plantuml/parser/integration_test/activity_diagram/parser/fork/output.json create mode 100644 plantuml/parser/integration_test/activity_diagram/parser/if/if.puml create mode 100644 plantuml/parser/integration_test/activity_diagram/parser/if/output.json create mode 100644 plantuml/parser/integration_test/activity_diagram/parser/repeat_while/output.json create mode 100644 plantuml/parser/integration_test/activity_diagram/parser/repeat_while/repeat_while.puml create mode 100644 plantuml/parser/integration_test/activity_diagram/parser/start_stop/output.json create mode 100644 plantuml/parser/integration_test/activity_diagram/parser/start_stop/start_stop.puml create mode 100644 plantuml/parser/integration_test/activity_diagram/parser/swimlane/output.json create mode 100644 plantuml/parser/integration_test/activity_diagram/parser/swimlane/swimlane.puml create mode 100644 plantuml/parser/integration_test/activity_diagram/parser/while/output.json create mode 100644 plantuml/parser/integration_test/activity_diagram/parser/while/while.puml create mode 100644 plantuml/parser/puml_parser/src/activity_diagram/src/activity_parser.rs create mode 100644 plantuml/parser/puml_parser/src/activity_diagram/src/creole.rs create mode 100644 plantuml/parser/puml_parser/src/activity_diagram/test/integration_test.rs diff --git a/plantuml/parser/integration_test/activity_diagram/BUILD b/plantuml/parser/integration_test/activity_diagram/BUILD new file mode 100644 index 00000000..311feffd --- /dev/null +++ b/plantuml/parser/integration_test/activity_diagram/BUILD @@ -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 +# ******************************************************************************* +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 index 00591a17..4d88693d 100644 --- a/plantuml/parser/integration_test/activity_diagram/activity_diagram.puml +++ b/plantuml/parser/integration_test/activity_diagram/activity_diagram.puml @@ -1,3 +1,15 @@ +' ******************************************************************************* +' 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" 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 00000000..b6699af2 --- /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 00000000..e260d704 --- /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 00000000..2d5f6811 --- /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 00000000..849d1947 --- /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 00000000..390350f8 --- /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 00000000..0575166c --- /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 00000000..db2ba889 --- /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 00000000..f03e5895 --- /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 00000000..211f0444 --- /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 00000000..14f7a1ea --- /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 00000000..64f182e3 --- /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 00000000..18eefa9c --- /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 + } + ] + } +} \ No newline at end of file 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 00000000..55848ed7 --- /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 00000000..4b3c78a1 --- /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 00000000..df9feccb --- /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 00000000..3a15530b --- /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 00000000..b38d0157 --- /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 00000000..a6d20351 --- /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 00000000..28a39697 --- /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 00000000..4dc881c6 --- /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 f6427729..f51296e8 100644 --- a/plantuml/parser/integration_test/src/test_error_view.rs +++ b/plantuml/parser/integration_test/src/test_error_view.rs @@ -14,7 +14,7 @@ use std::collections::HashMap; use std::path::Path; use puml_parser::{ - BaseParseError, ClassError, IncludeExpandError, IncludeParseError, PreprocessError, + 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::NotImplemented => { + let _ = base_dir; + ProjectedError::new("NotImplemented") + } + } + } +} + impl ErrorView for ElementResolverError { fn project(&self, _base_dir: &Path) -> ProjectedError { match self { diff --git a/plantuml/parser/puml_parser/src/activity_diagram/BUILD b/plantuml/parser/puml_parser/src/activity_diagram/BUILD index de8af0d4..5012f62a 100644 --- a/plantuml/parser/puml_parser/src/activity_diagram/BUILD +++ b/plantuml/parser/puml_parser/src/activity_diagram/BUILD @@ -10,12 +10,14 @@ # # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* -load("@rules_rust//rust:defs.bzl", "rust_library") +load("@rules_rust//rust:defs.bzl", "rust_library", "rust_test") filegroup( name = "puml_parser_activity_files", srcs = [ "src/activity_ast.rs", + "src/creole.rs", + "src/activity_parser.rs", "src/lib.rs", ], visibility = ["//plantuml/parser:__subpackages__"], @@ -24,7 +26,44 @@ filegroup( 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 index 6467dcd9..8ab1cbdd 100644 --- a/plantuml/parser/puml_parser/src/activity_diagram/src/activity_ast.rs +++ b/plantuml/parser/puml_parser/src/activity_diagram/src/activity_ast.rs @@ -21,9 +21,12 @@ pub struct RawActivityDiagram { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub enum RawActivityStmt { Action(ActionStmt), + Arrow(ArrowStmt), + Backward(BackwardStmt), Start(StartStmt), Stop(StopStmt), + Control(ControlStmt), // ===== If ===== IfStart(IfStartStmt), @@ -52,12 +55,35 @@ 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, @@ -75,10 +101,13 @@ 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 struct EndWhileStmt { + pub label: Option, +} #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct RepeatStartStmt; @@ -86,6 +115,7 @@ pub struct RepeatStartStmt; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct RepeatWhileStmt { pub condition: String, + pub label: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] 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 00000000..09bb3e94 --- /dev/null +++ b/plantuml/parser/puml_parser/src/activity_diagram/src/activity_parser.rs @@ -0,0 +1,409 @@ +// ******************************************************************************* +// 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("activity parser logic is not implemented yet")] + NotImplemented, +} + +impl ErrorLocation for ActivityParserError { + fn error_location(&self) -> Option<(usize, usize)> { + match self { + Self::Base(base) => base.error_location(), + Self::NotImplemented => 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(ActivityParserError::NotImplemented)?, + label, + }) + } + + fn parse_action_label( + pair: pest::iterators::Pair, + ) -> 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(ActivityParserError::NotImplemented) + } + + fn parse_action_stmt( + pair: pest::iterators::Pair, + ) -> Result { + let label = Self::parse_action_label(pair)?; + + Ok(ActionStmt { + label, + }) + } + + fn parse_backward_stmt( + pair: pest::iterators::Pair, + ) -> Result { + let label = Self::parse_action_label(pair)?; + + 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::NotImplemented), + }; + + 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(ActivityParserError::NotImplemented)?, + 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(ActivityParserError::NotImplemented)?, + 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(ActivityParserError::NotImplemented)?, + 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 mut kind = ForkEndKind::EndFork; + let mut modifier = None; + + for inner in pair.into_inner() { + match inner.as_rule() { + Rule::fork_modifier => { + modifier = match inner.as_str().trim() { + "{and}" => Some(ForkModifier::And), + "{or}" => Some(ForkModifier::Or), + _ => return Err(ActivityParserError::NotImplemented), + }; + } + _ => { + let text = inner.as_str().trim(); + if text == "end merge" { + kind = ForkEndKind::EndMerge; + } + } + } + } + + 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(ActivityParserError::NotImplemented)?; + + 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 00000000..82e15989 --- /dev/null +++ b/plantuml/parser/puml_parser/src/activity_diagram/src/creole.rs @@ -0,0 +1,305 @@ +// ******************************************************************************* +// 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 index fe9bc72b..56194d50 100644 --- a/plantuml/parser/puml_parser/src/activity_diagram/src/lib.rs +++ b/plantuml/parser/puml_parser/src/activity_diagram/src/lib.rs @@ -1,4 +1,4 @@ -// ***************************************************************************** +// ******************************************************************************* // Copyright (c) 2026 Contributors to the Eclipse Foundation // // See the NOTICE file(s) distributed with this work for additional @@ -9,8 +9,11 @@ // // // SPDX-License-Identifier: Apache-2.0 -// ***************************************************************************** +// ******************************************************************************* mod activity_ast; +mod creole; +mod activity_parser; 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 00000000..dcb03636 --- /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/activity.pest b/plantuml/parser/puml_parser/src/grammar/activity.pest index 118c5f77..81300690 100644 --- a/plantuml/parser/puml_parser/src/grammar/activity.pest +++ b/plantuml/parser/puml_parser/src/grammar/activity.pest @@ -15,10 +15,16 @@ activity_diagram = { ~ empty_line* ~ startuml ~ (statement | empty_line)* - ~ enduml + ~ activity_enduml ~ EOI } +activity_enduml = { + "@enduml" + ~ (WHITESPACE* ~ EOL)* + ~ WHITESPACE* +} + statement = _{ // fork fork_again_stmt @@ -39,7 +45,11 @@ statement = _{ | while_start_stmt // base + | note_declaration + | backward_stmt + | arrow_stmt | action_stmt + | control_stmt | swimlane_stmt | start_stmt | stop_stmt @@ -47,11 +57,35 @@ statement = _{ // 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 = @{ - (!(")" | NEWLINE) ~ ANY)+ + (condition_nested_group | condition_char)+ +} + +condition_nested_group = { + "(" + ~ (condition_nested_group | condition_char)* + ~ ")" +} + +condition_char = { + !("(" | ")" | NEWLINE) + ~ ANY } swimlane_text = @{ @@ -60,16 +94,27 @@ swimlane_text = @{ keyword_boundary = _{ !ASCII_ALPHANUMERIC } +control_stmt = { "break" | "kill" | "detach" } + // Action -action_stmt = { ":" ~ action_text ~ ";" } +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 ~ ")" - ~ "then" - ~ then_label? + ~ (if_pre_label ~ "then" | "then" ~ then_label?) } +if_pre_label = { "is" ~ "(" ~ condition_text ~ ")" } then_label = { "(" ~ condition_text ~ ")" } else_stmt = { @@ -83,9 +128,20 @@ endif_stmt = { "endif" } while_start_stmt = { "while" ~ "(" ~ condition_text ~ ")" + ~ while_label? } -endwhile_stmt = { "endwhile" } +while_label = { + "is" + ~ "(" ~ condition_text ~ ")" +} + +endwhile_stmt = { + "endwhile" + ~ endwhile_label? +} + +endwhile_label = { "(" ~ condition_text ~ ")" } // Repeat While repeat_start_stmt = { "repeat" } @@ -93,6 +149,12 @@ repeat_start_stmt = { "repeat" } repeat_while_stmt = { "repeat while" ~ "(" ~ condition_text ~ ")" + ~ repeat_label? +} + +repeat_label = { + "is" + ~ "(" ~ condition_text ~ ")" } // Fork @@ -114,7 +176,7 @@ fork_modifier = { } // Swimlane -swimlane_stmt = { "|" ~ swimlane_text ~ "|" } +swimlane_stmt = { "|" ~ (BASIC_COLOR ~ "|")? ~ swimlane_text ~ "|" } // Start/Stop start_stmt = { "start" } diff --git a/plantuml/parser/puml_parser/src/lib.rs b/plantuml/parser/puml_parser/src/lib.rs index 69340005..d90edfdc 100644 --- a/plantuml/parser/puml_parser/src/lib.rs +++ b/plantuml/parser/puml_parser/src/lib.rs @@ -12,7 +12,7 @@ // ******************************************************************************* // Re-export commonly used items that don't have name conflicts -pub use activity_parser::{RawActivityDiagram, RawActivityStmt}; +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 a0ad31e7..64d58ab2 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"] From f351b975b06098c048d78b324e2b39c43ece555e Mon Sep 17 00:00:00 2001 From: xiaobao Date: Tue, 19 May 2026 09:21:27 +0800 Subject: [PATCH 3/4] fix review items --- .../integration_test/src/test_error_view.rs | 4 +- .../activity_diagram/src/activity_parser.rs | 76 +++++++++++++------ .../puml_parser/src/grammar/activity.pest | 10 ++- 3 files changed, 61 insertions(+), 29 deletions(-) diff --git a/plantuml/parser/integration_test/src/test_error_view.rs b/plantuml/parser/integration_test/src/test_error_view.rs index f51296e8..37e01b07 100644 --- a/plantuml/parser/integration_test/src/test_error_view.rs +++ b/plantuml/parser/integration_test/src/test_error_view.rs @@ -201,9 +201,9 @@ impl ErrorView for ActivityParserError { fn project(&self, base_dir: &Path) -> ProjectedError { match self { ActivityParserError::Base(e) => e.project(base_dir), - ActivityParserError::NotImplemented => { + ActivityParserError::InvalidStatement(message) => { let _ = base_dir; - ProjectedError::new("NotImplemented") + ProjectedError::new("InvalidStatement").with_field("message", message.clone()) } } } 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 index 09bb3e94..9174ff33 100644 --- a/plantuml/parser/puml_parser/src/activity_diagram/src/activity_parser.rs +++ b/plantuml/parser/puml_parser/src/activity_diagram/src/activity_parser.rs @@ -34,15 +34,15 @@ use puml_utils::LogLevel; pub enum ActivityParserError { #[error(transparent)] Base(#[from] BaseParseError), - #[error("activity parser logic is not implemented yet")] - NotImplemented, + #[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(), - Self::NotImplemented => None, + _ => None, } } } @@ -108,13 +108,16 @@ impl PumlActivityParser { } Ok(ArrowStmt { - syntax: syntax.ok_or(ActivityParserError::NotImplemented)?, + 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() @@ -122,13 +125,18 @@ impl PumlActivityParser { matches!(inner.as_rule(), Rule::action_text | Rule::action_line_text) }) .map(|inner| normalize_creole_text(inner.as_str().trim())) - .ok_or(ActivityParserError::NotImplemented) + .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)?; + let label = Self::parse_action_label(pair, "action")?; Ok(ActionStmt { label, @@ -138,7 +146,7 @@ impl PumlActivityParser { fn parse_backward_stmt( pair: pest::iterators::Pair, ) -> Result { - let label = Self::parse_action_label(pair)?; + let label = Self::parse_action_label(pair, "backward")?; Ok(BackwardStmt { label }) } @@ -152,13 +160,18 @@ impl PumlActivityParser { } fn parse_control_stmt( - _pair: pest::iterators::Pair, + pair: pest::iterators::Pair, ) -> Result { - let kind = match _pair.as_str().trim() { + let kind = match pair.as_str().trim() { "break" => ControlKind::Break, "kill" => ControlKind::Kill, "detach" => ControlKind::Detach, - _ => return Err(ActivityParserError::NotImplemented), + _ => { + return Err(ActivityParserError::InvalidStatement(format!( + "invalid control kind: {}", + pair.as_str().trim(), + ))) + } }; Ok(ControlStmt { kind }) @@ -192,7 +205,9 @@ impl PumlActivityParser { } Ok(IfStartStmt { - condition: condition.ok_or(ActivityParserError::NotImplemented)?, + condition: condition.ok_or_else(|| { + ActivityParserError::InvalidStatement("missing if condition".to_string()) + })?, label, }) } @@ -232,7 +247,9 @@ impl PumlActivityParser { } Ok(WhileStartStmt { - condition: condition.ok_or(ActivityParserError::NotImplemented)?, + condition: condition.ok_or_else(|| { + ActivityParserError::InvalidStatement("missing while condition".to_string()) + })?, label, }) } @@ -281,7 +298,9 @@ impl PumlActivityParser { } Ok(RepeatWhileStmt { - condition: condition.ok_or(ActivityParserError::NotImplemented)?, + condition: condition.ok_or_else(|| { + ActivityParserError::InvalidStatement("missing repeat while condition".to_string()) + })?, label, }) } @@ -301,24 +320,29 @@ impl PumlActivityParser { fn parse_fork_end_stmt( pair: pest::iterators::Pair, ) -> Result { - let mut kind = ForkEndKind::EndFork; + let kind = if pair.as_str().contains("merge") { + ForkEndKind::EndMerge + } else { + ForkEndKind::EndFork + }; let mut modifier = None; for inner in pair.into_inner() { match inner.as_rule() { Rule::fork_modifier => { - modifier = match inner.as_str().trim() { - "{and}" => Some(ForkModifier::And), - "{or}" => Some(ForkModifier::Or), - _ => return Err(ActivityParserError::NotImplemented), + 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, + ))); }; } - _ => { - let text = inner.as_str().trim(); - if text == "end merge" { - kind = ForkEndKind::EndMerge; - } - } + _ => {} } } @@ -335,7 +359,9 @@ impl PumlActivityParser { .into_inner() .find(|inner| inner.as_rule() == Rule::swimlane_text) .map(|inner| normalize_creole_text(inner.as_str().trim())) - .ok_or(ActivityParserError::NotImplemented)?; + .ok_or_else(|| { + ActivityParserError::InvalidStatement("missing swimlane name".to_string()) + })?; Ok(SwimlaneStmt { name, diff --git a/plantuml/parser/puml_parser/src/grammar/activity.pest b/plantuml/parser/puml_parser/src/grammar/activity.pest index 81300690..1bdbe30f 100644 --- a/plantuml/parser/puml_parser/src/grammar/activity.pest +++ b/plantuml/parser/puml_parser/src/grammar/activity.pest @@ -164,12 +164,18 @@ fork_start_stmt = { } 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 = { - ("end fork" | "end merge") + fork_end_keyword ~ fork_modifier? } -fork_modifier = { +fork_modifier = ${ "{" ~ ("and" | "or") ~ "}" From 0592859bd195579e4ea2b5012fd0fc2fcbe2ad37 Mon Sep 17 00:00:00 2001 From: xiaobao Date: Tue, 19 May 2026 16:25:52 +0800 Subject: [PATCH 4/4] fix format check --- .../integration_test/activity_diagram/BUILD | 13 +- .../activity_diagram/parser/if/output.json | 2 +- .../integration_test/src/test_error_view.rs | 4 +- .../puml_parser/src/activity_diagram/BUILD | 2 +- .../activity_diagram/src/activity_parser.rs | 778 +++++++++--------- .../src/activity_diagram/src/creole.rs | 443 +++++----- .../src/activity_diagram/src/lib.rs | 2 +- plantuml/parser/puml_parser/src/lib.rs | 4 +- 8 files changed, 621 insertions(+), 627 deletions(-) diff --git a/plantuml/parser/integration_test/activity_diagram/BUILD b/plantuml/parser/integration_test/activity_diagram/BUILD index 311feffd..ccbbe995 100644 --- a/plantuml/parser/integration_test/activity_diagram/BUILD +++ b/plantuml/parser/integration_test/activity_diagram/BUILD @@ -12,10 +12,13 @@ # ******************************************************************************* filegroup( name = "activity_diagram_files", - srcs = glob([ - "**/*.puml", - "**/*.json", - "**/*.yaml", - ], allow_empty = True), + srcs = glob( + [ + "**/*.puml", + "**/*.json", + "**/*.yaml", + ], + allow_empty = True, + ), visibility = ["//visibility:public"], ) diff --git a/plantuml/parser/integration_test/activity_diagram/parser/if/output.json b/plantuml/parser/integration_test/activity_diagram/parser/if/output.json index 18eefa9c..d91598d0 100644 --- a/plantuml/parser/integration_test/activity_diagram/parser/if/output.json +++ b/plantuml/parser/integration_test/activity_diagram/parser/if/output.json @@ -63,4 +63,4 @@ } ] } -} \ No newline at end of file +} diff --git a/plantuml/parser/integration_test/src/test_error_view.rs b/plantuml/parser/integration_test/src/test_error_view.rs index 37e01b07..d08599c0 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::{ - ActivityParserError, BaseParseError, ClassError, IncludeExpandError, IncludeParseError, PreprocessError, - ProcedureExpandError, ProcedureParseError, + ActivityParserError, BaseParseError, ClassError, IncludeExpandError, IncludeParseError, + PreprocessError, ProcedureExpandError, ProcedureParseError, }; use puml_resolver::{ClassPumlResolverError, ElementResolverError}; diff --git a/plantuml/parser/puml_parser/src/activity_diagram/BUILD b/plantuml/parser/puml_parser/src/activity_diagram/BUILD index 5012f62a..a669a00e 100644 --- a/plantuml/parser/puml_parser/src/activity_diagram/BUILD +++ b/plantuml/parser/puml_parser/src/activity_diagram/BUILD @@ -16,8 +16,8 @@ filegroup( name = "puml_parser_activity_files", srcs = [ "src/activity_ast.rs", - "src/creole.rs", "src/activity_parser.rs", + "src/creole.rs", "src/lib.rs", ], visibility = ["//plantuml/parser:__subpackages__"], 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 index 9174ff33..7d36529b 100644 --- a/plantuml/parser/puml_parser/src/activity_diagram/src/activity_parser.rs +++ b/plantuml/parser/puml_parser/src/activity_diagram/src/activity_parser.rs @@ -17,419 +17,409 @@ 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, + 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, + 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), + #[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, - } - } + 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() { - match 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, - }) - } + 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) - } + 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 index 82e15989..1363725c 100644 --- a/plantuml/parser/puml_parser/src/activity_diagram/src/creole.rs +++ b/plantuml/parser/puml_parser/src/activity_diagram/src/creole.rs @@ -12,294 +12,293 @@ // ******************************************************************************* pub(crate) fn normalize_creole_text(text: &str) -> String { - let mut normalized_lines = Vec::new(); + let mut normalized_lines = Vec::new(); - for line in text.lines() { - normalized_lines.push(normalize_creole_line(line)); - } + 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(); - } + while normalized_lines.last().is_some_and(|line| line.is_empty()) { + normalized_lines.pop(); + } - normalized_lines.join("\n") + normalized_lines.join("\n") } fn normalize_creole_line(line: &str) -> String { - let trimmed = line.trim(); + let trimmed = line.trim(); - if trimmed.is_empty() || is_horizontal_rule(trimmed) { - return String::new(); - } + 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 let Some(heading) = strip_heading_markup(trimmed) { + return normalize_creole_inline(heading.trim()); + } - if looks_like_table_row(trimmed) { - return normalize_table_row(trimmed); - } + if looks_like_table_row(trimmed) { + return normalize_table_row(trimmed); + } - let content = strip_list_marker(trimmed).unwrap_or(trimmed); + let content = strip_list_marker(trimmed).unwrap_or(trimmed); - normalize_creole_inline(content) + 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) + 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(" ") + 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(); + 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 after.is_none() || after.is_some_and(char::is_whitespace) { + return false; + } - if marker == "//" && before == Some(':') { - return false; - } + if marker == "//" && before == Some(':') { + return false; + } - text[after_index..].contains(marker) + text[after_index..].contains(marker) } fn extract_bracketed_content(text: &str, open: &str, close: &str) -> Option { - if !text.starts_with(open) { - return None; - } + if !text.starts_with(open) { + return None; + } - let content = &text[open.len()..]; - let end = content.find(close)?; + let content = &text[open.len()..]; + let end = content.find(close)?; - Some(content[..end].to_string()) + Some(content[..end].to_string()) } fn normalize_link_content(content: &str) -> String { - let trimmed = content.trim(); - let (target, label) = split_first_unescaped_whitespace(trimmed); + let trimmed = content.trim(); + let (target, label) = split_first_unescaped_whitespace(trimmed); - if let Some(label) = label { - return normalize_creole_inline(label.trim()); - } + if let Some(label) = label { + return normalize_creole_inline(label.trim()); + } - strip_tooltip(target.trim()).to_string() + strip_tooltip(target.trim()).to_string() } fn normalize_image_content(content: &str) -> String { - let trimmed = content.trim(); + let trimmed = content.trim(); - if let Some((_, caption)) = trimmed.split_once('|') { - return normalize_creole_inline(caption.trim()); - } + if let Some((_, caption)) = trimmed.split_once('|') { + return normalize_creole_inline(caption.trim()); + } - String::new() + 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) + 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, - } + 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 - } + 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 + 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(" | ") + 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(); + 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; - } + if leading == 0 || trailing == 0 || text.len() <= leading + trailing { + return None; + } - Some(&text[leading..text.len() - trailing]) + 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) + 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 == '-') + 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"); - } + 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 index 56194d50..d5300298 100644 --- a/plantuml/parser/puml_parser/src/activity_diagram/src/lib.rs +++ b/plantuml/parser/puml_parser/src/activity_diagram/src/lib.rs @@ -12,8 +12,8 @@ // ******************************************************************************* mod activity_ast; -mod creole; 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/lib.rs b/plantuml/parser/puml_parser/src/lib.rs index d90edfdc..efe59557 100644 --- a/plantuml/parser/puml_parser/src/lib.rs +++ b/plantuml/parser/puml_parser/src/lib.rs @@ -12,7 +12,9 @@ // ******************************************************************************* // Re-export commonly used items that don't have name conflicts -pub use activity_parser::{ActivityParserError, PumlActivityParser, RawActivityDiagram, RawActivityStmt}; +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::{