Skip to content

Commit 0cad20d

Browse files
authored
Merge pull request #276 from hjotha/submit/sequence-flow-anchor-annotation
feat: @anchor annotation for microflow sequence flow endpoints
2 parents 0235cdb + 5fb2719 commit 0cad20d

37 files changed

Lines changed: 17168 additions & 14105 deletions

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
1313
- **Path normalization** — Relative paths in `MetadataUrl` are automatically converted to absolute `file://` URLs for Studio Pro compatibility
1414
- **ServiceUrl validation**`ServiceUrl` parameter must now be a constant reference (e.g., `@Module.ConstantName`) to enforce Mendix best practice
1515
- **Shared URL utilities**`internal/pathutil` package with `NormalizeURL()`, `URIToPath()`, and `PathFromURL()` for reuse across components
16+
- **@anchor sequence flow annotation** — optional `@anchor(from: X, to: Y)` annotation on microflow statements that pins the side of the activity box a SequenceFlow attaches to (top / right / bottom / left). Split (`if`) statements support the combined form `@anchor(to: X, true: (from: ..., to: ...), false: (from: ..., to: ...))` so the incoming and per-branch outgoing anchors survive describe → exec round-trip. When omitted, the builder derives the anchor from the visual flow direction (existing behaviour is unchanged). Keeps the flow diagram stable across patches when an agent-generated microflow is applied back to a project
17+
- **@anchor loop form**`LOOP`/`WHILE` statements accept `@anchor(iterator: (...), tail: (...))` in the grammar so authoring tools can forward-propagate the intent. Today the builder deliberately does not translate those into SequenceFlows: Studio Pro rejects edges between a `LoopedActivity` and its body statements with CE0709 ("Sequence flow is not accepted by origin or destination"), since the iterator icon is drawn implicitly from the loop geometry. Reserving the grammar slot keeps scripts forward-compatible with any future Mendix capability
1618

1719
### Changed
1820

cmd/gen-completions/main.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,8 @@ func normalizeCategoryName(raw string) string {
304304
return "Connection keyword"
305305
case strings.Contains(raw, "OQL"), strings.Contains(raw, "QUERY"):
306306
return "Query keyword"
307+
case strings.Contains(raw, "ANCHOR"):
308+
return "Flow annotation keyword"
307309
case strings.Contains(raw, "MICROFLOW"):
308310
return "Microflow keyword"
309311
case strings.Contains(raw, "PAGE"), strings.Contains(raw, "WIDGET"):

cmd/mxcli/lsp_completions_gen.go

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
-- ============================================================================
2+
-- Feature: @anchor annotation for sequence flow endpoints
3+
-- ============================================================================
4+
--
5+
-- Motivation:
6+
-- Studio Pro renders SequenceFlows connecting to one of the four sides of
7+
-- each activity box (top, right, bottom, left). Until this annotation was
8+
-- added, the builder always chose the anchor side from the flow direction
9+
-- (horizontal → right-to-left, upward → right-to-bottom, etc.) and the
10+
-- describer dropped the information entirely. A DESCRIBE → re-execute
11+
-- round-trip therefore lost every manual anchor adjustment and the flow
12+
-- diagram had to be re-tidied by hand after each iteration.
13+
--
14+
-- Syntax:
15+
-- @anchor(from: X, to: Y) — anchors for the single outgoing flow
16+
-- @anchor(true: (from:..., to:...), false: (from:..., to:...)) — IF
17+
-- @anchor(iterator: (from:..., to:...), tail: (from:..., to:...)) — LOOP/WHILE
18+
--
19+
-- Each side is independently optional. Missing sides fall back to the
20+
-- builder's default for the visual flow direction.
21+
--
22+
-- Usage:
23+
-- mxcli exec mdl-examples/bug-tests/anchor-sequence-flow-annotation.mdl -p app.mpr
24+
-- Then describe each microflow below with `mxcli describe microflow ...`.
25+
--
26+
-- The file has two sections with different roundtrip guarantees:
27+
--
28+
-- A) Roundtrip-preserving examples (flat @anchor + IF split form) — the
29+
-- describe output MUST include the @anchor(...) lines verbatim.
30+
-- Executing describe's output back into the project is bit-exact.
31+
--
32+
-- B) Parse-only forward-compatibility examples (LOOP / WHILE iterator
33+
-- and tail sides) — the grammar accepts @anchor(iterator: ..., tail: ...)
34+
-- but the builder intentionally does NOT emit those SequenceFlows today
35+
-- (see the note at the top of section B). Describe therefore will NOT
36+
-- echo iterator/tail back; the annotation is accepted so existing
37+
-- scripts keep parsing if Mendix ever starts recording those edges.
38+
-- ============================================================================
39+
40+
-- ============================================================================
41+
-- Section A — roundtrip-preserving examples
42+
-- ============================================================================
43+
44+
create module BugTestAnchor;
45+
46+
create microflow BugTestAnchor.MF_CustomAnchors ()
47+
returns string as $result
48+
begin
49+
declare $result string = empty;
50+
51+
@anchor(from: bottom)
52+
log info node 'App' 'first';
53+
54+
@anchor(to: top)
55+
log info node 'App' 'second';
56+
57+
set $result = 'done';
58+
return $result;
59+
end;
60+
/
61+
62+
create microflow BugTestAnchor.MF_IfBranchAnchors ()
63+
returns string as $result
64+
begin
65+
declare $result string = empty;
66+
67+
@anchor(true: (from: right, to: left), false: (from: bottom, to: top))
68+
if true then
69+
set $result = 'yes';
70+
else
71+
set $result = 'no';
72+
end if;
73+
74+
return $result;
75+
end;
76+
/
77+
78+
-- ============================================================================
79+
-- Section B — parse-only forward-compatibility examples (LOOP / WHILE)
80+
-- ============================================================================
81+
-- The grammar accepts @anchor(iterator: (...), tail: (...)) on LOOP and
82+
-- WHILE statements so authoring tools can forward-propagate the intent, but
83+
-- the builder deliberately does NOT translate them into SequenceFlows.
84+
-- Mendix rejects edges between a LoopedActivity and its body statements with
85+
-- CE0709 "Sequence flow is not accepted by origin or destination" — the
86+
-- iterator icon is drawn implicitly from the LoopedActivity geometry.
87+
--
88+
-- NOTE: describe will not echo `iterator: (...)` / `tail: (...)` in the
89+
-- output for the two microflows below. That is expected — see the usage
90+
-- text above.
91+
92+
create entity BugTestAnchor.Item (
93+
Label: String(100)
94+
);
95+
96+
create microflow BugTestAnchor.MF_LoopAnchors ()
97+
begin
98+
retrieve $items from BugTestAnchor.Item;
99+
100+
@anchor(iterator: (from: bottom, to: top), tail: (from: right, to: bottom))
101+
loop $item in $items begin
102+
log info node 'App' 'step';
103+
end loop;
104+
end;
105+
/
106+
107+
create microflow BugTestAnchor.MF_WhileAnchors ()
108+
begin
109+
@anchor(iterator: (from: bottom, to: left), tail: (from: top, to: right))
110+
while true begin
111+
log info node 'App' 'tick';
112+
end while;
113+
end;
114+
/

mdl/ast/ast_microflow.go

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -105,14 +105,48 @@ type RaiseErrorStmt struct {
105105

106106
func (s *RaiseErrorStmt) isMicroflowStatement() {}
107107

108+
// AnchorSide identifies one of the four sides of an activity's visual box
109+
// that a SequenceFlow attaches to.
110+
type AnchorSide int
111+
112+
const (
113+
AnchorSideUnset AnchorSide = -1
114+
AnchorSideTop AnchorSide = 0
115+
AnchorSideRight AnchorSide = 1
116+
AnchorSideBottom AnchorSide = 2
117+
AnchorSideLeft AnchorSide = 3
118+
)
119+
120+
// FlowAnchors captures the origin/destination anchors for a single
121+
// SequenceFlow. Each side is independently optional: Unset means the builder
122+
// should derive the anchor from the visual direction.
123+
type FlowAnchors struct {
124+
From AnchorSide // OriginConnectionIndex on the outgoing SequenceFlow
125+
To AnchorSide // DestinationConnectionIndex on the outgoing SequenceFlow
126+
}
127+
108128
// ActivityAnnotations holds metadata annotations for microflow activities.
109-
// These are emitted as @position, @caption, @color, @annotation, @excluded lines in MDL.
129+
// These are emitted as @position, @caption, @color, @annotation, @excluded, @anchor lines in MDL.
110130
type ActivityAnnotations struct {
111-
Position *Position // @position(x, y)
112-
Caption string // @caption 'text'
113-
Color string // @color Green
114-
AnnotationText string // @annotation 'text'
115-
Excluded bool // @excluded
131+
Position *Position // @position(x, y)
132+
Caption string // @caption 'text'
133+
Color string // @color Green
134+
AnnotationText string // @annotation 'text'
135+
Excluded bool // @excluded
136+
Anchor *FlowAnchors // @anchor(from: X, to: Y) — anchors of the flow leaving this statement
137+
138+
// Split-specific anchors for IF statements. When the statement is not an
139+
// IF these remain nil. The grammar accepts them on IfStmt only:
140+
// @anchor(true: (from: right, to: left), false: (from: bottom, to: left))
141+
TrueBranchAnchor *FlowAnchors
142+
FalseBranchAnchor *FlowAnchors
143+
144+
// Loop body anchors for LOOP/WHILE. IteratorAnchor is the flow that
145+
// enters the loop body from the iterator; BodyTailAnchor is the flow
146+
// from the last body statement back to the loop boundary. Both are only
147+
// populated on LoopStmt/WhileStmt.
148+
IteratorAnchor *FlowAnchors
149+
BodyTailAnchor *FlowAnchors
116150
}
117151

118152
// ChangeItem represents a single assignment in CREATE/CHANGE: Attr = expr
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
// Regression tests for anchor preservation inside IF branches — the original
4+
// @anchor implementation only handled the top-level flow between statements
5+
// at the microflow body level. Anchors on statements inside THEN/ELSE bodies
6+
// (including the flow between a branch's first statement and its successors,
7+
// and the flow leaving the last branch statement to the merge) were silently
8+
// dropped, so real-world microflows like the attempt #35 repro case lost
9+
// every vertical anchor on roundtrip.
10+
package executor
11+
12+
import (
13+
"testing"
14+
15+
"github.com/mendixlabs/mxcli/mdl/ast"
16+
)
17+
18+
// buildWithAnchors is a test helper that builds the flow graph for a simple
19+
// microflow body and returns the collection for inspection.
20+
func buildWithAnchors(body []ast.MicroflowStatement) (oc *struct {
21+
Flows []anchorFlow
22+
Objects int
23+
}) {
24+
fb := &flowBuilder{posX: 100, posY: 100, spacing: HorizontalSpacing}
25+
col := fb.buildFlowGraph(body, nil)
26+
oc = &struct {
27+
Flows []anchorFlow
28+
Objects int
29+
}{
30+
Objects: len(col.Objects),
31+
}
32+
for _, f := range col.Flows {
33+
oc.Flows = append(oc.Flows, anchorFlow{
34+
OriginIdx: f.OriginConnectionIndex,
35+
DestIdx: f.DestinationConnectionIndex,
36+
})
37+
}
38+
return oc
39+
}
40+
41+
type anchorFlow struct {
42+
OriginIdx int
43+
DestIdx int
44+
}
45+
46+
// hasFlow returns true when at least one flow has the given anchor pair.
47+
func hasFlow(flows []anchorFlow, origin, dest int) bool {
48+
for _, f := range flows {
49+
if f.OriginIdx == origin && f.DestIdx == dest {
50+
return true
51+
}
52+
}
53+
return false
54+
}
55+
56+
// countFlows counts how many flows have the given anchor pair.
57+
func countFlows(flows []anchorFlow, origin, dest int) int {
58+
n := 0
59+
for _, f := range flows {
60+
if f.OriginIdx == origin && f.DestIdx == dest {
61+
n++
62+
}
63+
}
64+
return n
65+
}
66+
67+
func TestBuilder_AnchorInsideElseBranch(t *testing.T) {
68+
// Reproduces the pattern from attempt #35:
69+
// if cond then { set ... }
70+
// else {
71+
// @anchor(from: bottom, to: top)
72+
// log ...
73+
// @anchor(to: top)
74+
// return empty
75+
// }
76+
body := []ast.MicroflowStatement{
77+
&ast.IfStmt{
78+
Condition: &ast.LiteralExpr{Kind: ast.LiteralBoolean, Value: true},
79+
ThenBody: []ast.MicroflowStatement{
80+
&ast.LogStmt{Level: ast.LogInfo, Message: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "a"}},
81+
},
82+
ElseBody: []ast.MicroflowStatement{
83+
&ast.LogStmt{
84+
Level: ast.LogInfo,
85+
Message: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "b"},
86+
Annotations: &ast.ActivityAnnotations{
87+
Anchor: &ast.FlowAnchors{From: ast.AnchorSideBottom, To: ast.AnchorSideTop},
88+
},
89+
},
90+
&ast.ReturnStmt{
91+
Value: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "done"},
92+
Annotations: &ast.ActivityAnnotations{
93+
Anchor: &ast.FlowAnchors{To: ast.AnchorSideTop, From: ast.AnchorSideUnset},
94+
},
95+
},
96+
},
97+
},
98+
}
99+
100+
oc := buildWithAnchors(body)
101+
102+
// Two distinct Bottom→Top flows must exist:
103+
// 1. split → log (from the user's @anchor on the log statement)
104+
// 2. log → return (propagating the log's From=Bottom and the return's To=Top)
105+
// A single hasFlow check would pass with just one match, so count explicitly
106+
// to pin the regression — see ako review note on TestBuilder_AnchorInsideElseBranch.
107+
if got := countFlows(oc.Flows, AnchorBottom, AnchorTop); got != 2 {
108+
t.Errorf("expected 2 Bottom→Top flows (split→log and log→return), got %d: %+v", got, oc.Flows)
109+
}
110+
}
111+
112+
func TestBuilder_AnchorToTopOnReturnPreservedInsideElse(t *testing.T) {
113+
// Minimal case: single-statement ELSE whose only statement is a RETURN
114+
// carrying @anchor(to: top). The flow from the split to that return's
115+
// EndEvent must land on DestinationConnectionIndex = AnchorTop.
116+
body := []ast.MicroflowStatement{
117+
&ast.IfStmt{
118+
Condition: &ast.LiteralExpr{Kind: ast.LiteralBoolean, Value: true},
119+
ThenBody: []ast.MicroflowStatement{
120+
&ast.LogStmt{Level: ast.LogInfo, Message: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "a"}},
121+
},
122+
ElseBody: []ast.MicroflowStatement{
123+
&ast.ReturnStmt{
124+
Value: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "no"},
125+
Annotations: &ast.ActivityAnnotations{
126+
Anchor: &ast.FlowAnchors{To: ast.AnchorSideTop, From: ast.AnchorSideUnset},
127+
},
128+
},
129+
},
130+
},
131+
}
132+
133+
oc := buildWithAnchors(body)
134+
135+
// Default downward flow from split has OriginConnectionIndex=Bottom; with
136+
// @anchor(to: top) on the return, DestinationConnectionIndex must be Top.
137+
if !hasFlow(oc.Flows, AnchorBottom, AnchorTop) {
138+
t.Errorf("expected split→return flow with Bottom→Top, got %+v", oc.Flows)
139+
}
140+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
// Regression tests for @annotation / @caption escaping in the microflow
4+
// describer. The free-annotation emission path used a home-grown escape that
5+
// only handled apostrophe doubling — newlines, tabs, and backslashes inside
6+
// the annotation text were emitted as raw control characters, which then
7+
// tripped the parser when the output was fed back through `mxcli exec`.
8+
//
9+
// The fix switches both the free-annotation and the ExclusiveSplit/
10+
// InheritanceSplit @caption emission to `mdlQuote`, which handles the full
11+
// set of MDL-sensitive characters and matches the single quoted-source helper
12+
// used everywhere else.
13+
package executor
14+
15+
import (
16+
"strings"
17+
"testing"
18+
)
19+
20+
func TestMdlQuote_EscapesNewlinesAndBackslashes(t *testing.T) {
21+
in := "SvdV (24/Mar/2021):\r\n\r\nThis microflow uses \\d in a regex."
22+
out := mdlQuote(in)
23+
24+
if strings.ContainsRune(out, '\n') {
25+
t.Errorf("mdlQuote output must not contain a raw newline, got %q", out)
26+
}
27+
if strings.ContainsRune(out, '\r') {
28+
t.Errorf("mdlQuote output must not contain a raw carriage return, got %q", out)
29+
}
30+
for _, want := range []string{`\r`, `\n`, `\\d`} {
31+
if !strings.Contains(out, want) {
32+
t.Errorf("mdlQuote output missing escaped sequence %q; got %q", want, out)
33+
}
34+
}
35+
if !strings.HasPrefix(out, "'") || !strings.HasSuffix(out, "'") {
36+
t.Errorf("mdlQuote output should be wrapped in single quotes: %q", out)
37+
}
38+
}
39+
40+
func TestMdlQuote_EscapesApostrophesByDoubling(t *testing.T) {
41+
in := "it's here"
42+
out := mdlQuote(in)
43+
if out != "'it''s here'" {
44+
t.Errorf("got %q, want %q", out, "'it''s here'")
45+
}
46+
}

0 commit comments

Comments
 (0)