Skip to content

Commit 6dc86b0

Browse files
authored
Merge pull request #319 from hjotha/submit/microflow-free-annotation
feat: support free microflow annotations
2 parents a07e7e0 + ea6c331 commit 6dc86b0

16 files changed

Lines changed: 441 additions & 29 deletions

.claude/skills/mendix/write-microflows.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -559,6 +559,7 @@ commit $Product;
559559

560560
**Rules:**
561561
- `@annotation` before an activity attaches the note to that activity
562+
- `@annotation` before activity-binding metadata such as `@position`, `@caption`, `@color`, `@excluded`, or `@anchor` stays free-floating when later metadata binds the following activity
562563
- `@annotation` at the end (no following activity) creates a free-floating note
563564
- Escape single quotes by doubling: `@annotation 'Don''t forget'`
564565
- `@position` always appears in DESCRIBE output; `@caption` only when custom; `@color` only when not Default

docs/01-project/MDL_QUICK_REFERENCE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,7 @@ authentication basic, session
237237
| Caption | `@caption 'text'` | Custom caption (before activity) |
238238
| Color | `@color Green` | Background color (before activity) |
239239
| Annotation | `@annotation 'text'` | Visual note attached to next activity |
240+
| Free annotation | `@annotation 'text'` before `@position(...)` | Free-floating visual note preserved by order |
240241
| IF | `if condition then ... [else ...] end if;` | |
241242
| LOOP | `loop $item in $list begin ... end loop;` | FOR EACH over list |
242243
| WHILE | `while condition begin ... end while;` | Condition-based loop |
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# Microflow Free Annotation
2+
3+
Status: Draft
4+
5+
## Summary
6+
7+
Add explicit round-trip support for free-floating microflow annotations that are
8+
serialized next to an activity in MDL but are not visually attached to that
9+
activity in the Mendix model.
10+
11+
## Motivation
12+
13+
Mendix stores some visual notes as standalone annotations. During `describe`,
14+
those notes can appear immediately before an activity because the activity is
15+
the next stable textual anchor. If the parser treats every preceding
16+
`@annotation` as activity metadata, `exec` rewrites the note into an attached
17+
annotation flow and changes the diagram.
18+
19+
## Semantics
20+
21+
`@annotation 'text'` is treated as a free annotation when both conditions hold:
22+
23+
1. It appears before any activity-binding metadata for the same statement.
24+
2. A later activity-binding annotation follows before the activity statement.
25+
26+
Activity-binding metadata is currently `@position`, `@caption`, `@color`,
27+
`@excluded`, or `@anchor`.
28+
29+
Example:
30+
31+
```mdl
32+
@annotation 'section header'
33+
@position(100, 200)
34+
log info node 'Audit' 'starting';
35+
```
36+
37+
The note remains free-floating. By contrast, an annotation after `@position` is
38+
still attached to the activity:
39+
40+
```mdl
41+
@position(100, 200)
42+
@annotation 'activity note'
43+
log info node 'Audit' 'starting';
44+
```
45+
46+
## Tests And Examples
47+
48+
- `mdl-examples/doctype-tests/free_annotation.test.mdl` documents the supported
49+
syntax.
50+
- Parser tests cover both order-sensitive cases.
51+
- Builder tests verify that the free annotation is emitted as a standalone
52+
annotation and not attached to the activity.
53+
54+
## Open Questions
55+
56+
- Should free annotation binding use textual order only, or should it also
57+
consider visual proximity in the microflow diagram?
58+
- Should MDL grow an explicit keyword for free annotations to avoid relying on
59+
order-sensitive disambiguation?

docs/11-proposals/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ BSON schema Registry ◄──── multi-version Support
4343
|----------|--------|---------|------------|
4444
| [MDL Syntax Improvements v1](PROPOSAL_mdl_syntax_improvements.md) | Draft | Go-style assignment, C-style braces, fluent list APIs ||
4545
| [MDL Syntax Improvements v2](PROPOSAL_mdl_syntax_improvements_v2.md) | Proposed | Consolidated v2: unified variable declaration, C-style braces, fluent list ops | Syntax Improvements v1 |
46+
| [Microflow Free Annotation](PROPOSAL_microflow_free_annotation.md) | Draft | Order-sensitive `@annotation` handling for free-floating visual notes in microflows ||
4647
| [Microflow Download File Statement](PROPOSAL_microflow_download_file_statement.md) | Draft | `download file $FileDocument [show in browser]` for `DownloadFileAction` round-trip and authoring ||
4748
| [Page Syntax V2](PROPOSAL_page_syntax_v2.md) | Superseded | Page/widget syntax with `{}` blocks and `->` binding. Superseded by V3 (archived) ||
4849
| [Page Styling Support](page-styling-support.md) | Partial | CSS classes, inline styles, dynamic classes, design properties. Phase 1 (Class/Style) done ||
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
create microflow SampleAnnotations.ACT_FreeAnnotation ()
2+
returns Void
3+
begin
4+
@annotation 'section header'
5+
@position(100, 200)
6+
log info node 'Sample' 'start';
7+
8+
@position(300, 200)
9+
@annotation 'activity note'
10+
log info node 'Sample' 'attached';
11+
12+
return;
13+
end;
14+
/

mdl/ast/ast_microflow.go

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -150,12 +150,14 @@ type FlowAnchors struct {
150150
// ActivityAnnotations holds metadata annotations for microflow activities.
151151
// These are emitted as @position, @caption, @color, @annotation, @excluded, @anchor lines in MDL.
152152
type ActivityAnnotations struct {
153-
Position *Position // @position(x, y)
154-
Caption string // @caption 'text'
155-
Color string // @color Green
156-
AnnotationText string // @annotation 'text'
157-
Excluded bool // @excluded
158-
Anchor *FlowAnchors // @anchor(from: X, to: Y) — anchors of the flow leaving this statement
153+
Position *Position // @position(x, y)
154+
Caption string // @caption 'text'
155+
Color string // @color Green
156+
AnnotationText string // @annotation 'text'
157+
FreeAnnotation string // @annotation 'text' before @position/@anchor, kept free-floating
158+
FreeAnnotations []string // Multiple free-floating @annotation lines in source order
159+
Excluded bool // @excluded
160+
Anchor *FlowAnchors // @anchor(from: X, to: Y) — anchors of the flow leaving this statement
159161

160162
// Split-specific anchors for IF statements. When the statement is not an
161163
// IF these remain nil. The grammar accepts them on IfStmt only:

mdl/executor/cmd_microflows_builder_annotations.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,12 @@ func (fb *flowBuilder) mergeStatementAnnotations(stmt ast.MicroflowStatement) {
119119
if ann.AnnotationText != "" {
120120
fb.pendingAnnotations.AnnotationText = ann.AnnotationText
121121
}
122+
if texts := freeAnnotationTexts(ann); len(texts) > 0 {
123+
fb.pendingAnnotations.FreeAnnotations = append(fb.pendingAnnotations.FreeAnnotations, texts...)
124+
if fb.pendingAnnotations.FreeAnnotation == "" {
125+
fb.pendingAnnotations.FreeAnnotation = texts[0]
126+
}
127+
}
122128
if ann.Anchor != nil {
123129
fb.pendingAnnotations.Anchor = ann.Anchor
124130
}
@@ -191,6 +197,14 @@ func (fb *flowBuilder) applyAnnotations(activityID model.ID, ann *ast.ActivityAn
191197
}
192198
}
193199

200+
func (fb *flowBuilder) applyPendingAnnotations(activityID model.ID) {
201+
if activityID == "" || fb.pendingAnnotations == nil {
202+
return
203+
}
204+
fb.applyAnnotations(activityID, fb.pendingAnnotations)
205+
fb.pendingAnnotations = nil
206+
}
207+
194208
// addEndEventWithReturn creates an EndEvent with the specified return value.
195209
// This produces an actual EndEvent activity in the flow graph, allowing RETURN
196210
// to work correctly inside IF/ELSE branches and error handler bodies.
@@ -275,3 +289,19 @@ func (fb *flowBuilder) attachFreeAnnotation(text string) {
275289
}
276290
fb.objects = append(fb.objects, annotation)
277291
}
292+
293+
func freeAnnotationTexts(ann *ast.ActivityAnnotations) []string {
294+
if ann == nil {
295+
return nil
296+
}
297+
texts := append([]string(nil), ann.FreeAnnotations...)
298+
if ann.FreeAnnotation != "" {
299+
for _, text := range texts {
300+
if text == ann.FreeAnnotation {
301+
return texts
302+
}
303+
}
304+
texts = append(texts, ann.FreeAnnotation)
305+
}
306+
return texts
307+
}

mdl/executor/cmd_microflows_builder_annotations_test.go

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"testing"
77

88
"github.com/mendixlabs/mxcli/mdl/ast"
9+
"github.com/mendixlabs/mxcli/model"
910
"github.com/mendixlabs/mxcli/sdk/microflows"
1011
)
1112

@@ -310,3 +311,131 @@ func TestInheritanceSplitCaptionApplied(t *testing.T) {
310311
t.Errorf("inheritance split caption: got %q, want %q", split.Caption, "Customer type?")
311312
}
312313
}
314+
315+
func TestFreeAnnotationBeforePositionStaysUnattached(t *testing.T) {
316+
body := []ast.MicroflowStatement{
317+
&ast.LogStmt{
318+
Level: ast.LogInfo,
319+
Message: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "message"},
320+
Annotations: &ast.ActivityAnnotations{
321+
FreeAnnotation: "free synthetic note",
322+
Position: &ast.Position{X: 120, Y: 240},
323+
},
324+
},
325+
}
326+
327+
fb := &flowBuilder{posX: 100, posY: 100, spacing: HorizontalSpacing}
328+
oc := fb.buildFlowGraph(body, nil)
329+
330+
freeAnnotations := collectFreeAnnotations(oc)
331+
if len(freeAnnotations) != 1 || freeAnnotations[0] != "free synthetic note" {
332+
t.Fatalf("free annotations = %#v, want one free note", freeAnnotations)
333+
}
334+
335+
attached := buildAnnotationsByTarget(oc)
336+
for activityID, captions := range attached {
337+
for _, caption := range captions {
338+
if caption == "free synthetic note" {
339+
t.Fatalf("free note was attached to activity %s", activityID)
340+
}
341+
}
342+
}
343+
}
344+
345+
func TestMultipleFreeAnnotationsBeforePositionStayUnattached(t *testing.T) {
346+
body := []ast.MicroflowStatement{
347+
&ast.LogStmt{
348+
Level: ast.LogInfo,
349+
Message: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "message"},
350+
Annotations: &ast.ActivityAnnotations{
351+
FreeAnnotations: []string{"first free note", "second free note"},
352+
Position: &ast.Position{X: 120, Y: 240},
353+
},
354+
},
355+
}
356+
357+
fb := &flowBuilder{posX: 100, posY: 100, spacing: HorizontalSpacing}
358+
oc := fb.buildFlowGraph(body, nil)
359+
360+
freeAnnotations := collectFreeAnnotations(oc)
361+
want := []string{"first free note", "second free note"}
362+
if len(freeAnnotations) != len(want) {
363+
t.Fatalf("free annotations = %#v, want %#v", freeAnnotations, want)
364+
}
365+
for i, wantText := range want {
366+
if freeAnnotations[i] != wantText {
367+
t.Fatalf("free annotation %d = %q, want %q", i, freeAnnotations[i], wantText)
368+
}
369+
}
370+
}
371+
372+
func TestIfBranchActionCaptionStaysWithAction(t *testing.T) {
373+
ifStmt := &ast.IfStmt{
374+
Condition: &ast.LiteralExpr{Kind: ast.LiteralBoolean, Value: true},
375+
ThenBody: []ast.MicroflowStatement{
376+
&ast.LogStmt{
377+
Level: ast.LogInfo,
378+
Message: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "branch"},
379+
Annotations: &ast.ActivityAnnotations{Caption: "Branch activity"},
380+
},
381+
},
382+
}
383+
384+
fb := &flowBuilder{posX: 100, posY: 100, spacing: HorizontalSpacing}
385+
fb.buildFlowGraph([]ast.MicroflowStatement{ifStmt}, nil)
386+
387+
for _, obj := range fb.objects {
388+
activity, ok := obj.(*microflows.ActionActivity)
389+
if !ok {
390+
continue
391+
}
392+
if _, ok := activity.Action.(*microflows.LogMessageAction); ok {
393+
if activity.Caption != "Branch activity" {
394+
t.Fatalf("branch activity caption = %q, want Branch activity", activity.Caption)
395+
}
396+
if activity.AutoGenerateCaption {
397+
t.Fatal("branch activity caption should not be autogenerated")
398+
}
399+
return
400+
}
401+
}
402+
t.Fatal("expected branch log activity")
403+
}
404+
405+
func TestIfBranchActionAnnotationStaysWithAction(t *testing.T) {
406+
ifStmt := &ast.IfStmt{
407+
Condition: &ast.LiteralExpr{Kind: ast.LiteralBoolean, Value: true},
408+
ThenBody: []ast.MicroflowStatement{
409+
&ast.LogStmt{
410+
Level: ast.LogInfo,
411+
Message: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "branch"},
412+
Annotations: &ast.ActivityAnnotations{
413+
AnnotationText: "Branch note",
414+
},
415+
},
416+
},
417+
}
418+
419+
fb := &flowBuilder{posX: 100, posY: 100, spacing: HorizontalSpacing}
420+
oc := fb.buildFlowGraph([]ast.MicroflowStatement{ifStmt}, nil)
421+
422+
var logID model.ID
423+
for _, obj := range oc.Objects {
424+
activity, ok := obj.(*microflows.ActionActivity)
425+
if !ok {
426+
continue
427+
}
428+
if _, ok := activity.Action.(*microflows.LogMessageAction); ok {
429+
logID = activity.ID
430+
break
431+
}
432+
}
433+
if logID == "" {
434+
t.Fatal("expected branch log activity")
435+
}
436+
437+
attached := buildAnnotationsByTarget(oc)
438+
if got := attached[logID]; len(got) != 1 || got[0] != "Branch note" {
439+
t.Fatalf("branch log annotations = %#v, want [Branch note]", got)
440+
}
441+
}

mdl/executor/cmd_microflows_builder_control.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ func (fb *flowBuilder) addIfStatement(s *ast.IfStmt) model.ID {
119119
thisAnchor := stmtOwnAnchor(stmt)
120120
actID := fb.addStatement(stmt)
121121
if actID != "" {
122+
fb.applyPendingAnnotations(actID)
122123
if lastThenID == "" {
123124
// First statement in THEN - connect from split with "true" case.
124125
// Origin: trueBranchAnchor.From (if set) — anchor on the split side.
@@ -173,6 +174,7 @@ func (fb *flowBuilder) addIfStatement(s *ast.IfStmt) model.ID {
173174
thisAnchor := stmtOwnAnchor(stmt)
174175
actID := fb.addStatement(stmt)
175176
if actID != "" {
177+
fb.applyPendingAnnotations(actID)
176178
if lastElseID == "" {
177179
// First statement in ELSE - connect from split going down (false path).
178180
// Same compositional rule as the THEN branch.
@@ -232,6 +234,7 @@ func (fb *flowBuilder) addIfStatement(s *ast.IfStmt) model.ID {
232234
thisAnchor := stmtOwnAnchor(stmt)
233235
actID := fb.addStatement(stmt)
234236
if actID != "" {
237+
fb.applyPendingAnnotations(actID)
235238
if lastThenID == "" {
236239
// First statement in THEN - connect from split going down with "true" case
237240
flow := newDownwardFlowWithCase(splitID, actID, "true")
@@ -379,6 +382,7 @@ func (fb *flowBuilder) addLoopStatement(s *ast.LoopStmt) model.ID {
379382
for _, stmt := range s.Body {
380383
actID := loopBuilder.addStatement(stmt)
381384
if actID != "" {
385+
loopBuilder.applyPendingAnnotations(actID)
382386
if lastBodyID != "" {
383387
loopBuilder.flows = append(loopBuilder.flows, newHorizontalFlow(lastBodyID, actID))
384388
}
@@ -617,6 +621,7 @@ func (fb *flowBuilder) addWhileStatement(s *ast.WhileStmt) model.ID {
617621
for _, stmt := range s.Body {
618622
actID := loopBuilder.addStatement(stmt)
619623
if actID != "" {
624+
loopBuilder.applyPendingAnnotations(actID)
620625
if lastBodyID != "" {
621626
loopBuilder.flows = append(loopBuilder.flows, newHorizontalFlow(lastBodyID, actID))
622627
}

mdl/executor/cmd_microflows_builder_flows.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ func (fb *flowBuilder) addErrorHandlerFlow(sourceActivityID model.ID, sourceX in
7373
for _, stmt := range errorBody {
7474
actID := errBuilder.addStatement(stmt)
7575
if actID != "" {
76+
errBuilder.applyPendingAnnotations(actID)
7677
if lastErrID == "" {
7778
// Connect source activity to first error handler activity
7879
fb.flows = append(fb.flows, newErrorHandlerFlow(sourceActivityID, actID))

0 commit comments

Comments
 (0)