From 8550e85ef5e92cf61711dbb8782c75ba12336897 Mon Sep 17 00:00:00 2001 From: Henrique Costa Date: Sun, 26 Apr 2026 11:46:38 +0200 Subject: [PATCH 1/3] feat: support free microflow annotations Symptom: standalone Mendix annotation notes that describe emits immediately before an activity were parsed as activity annotations and became attached to that activity on exec. Root cause: the MDL annotation model only had one annotation text field, so order-sensitive free notes and activity-bound notes were collapsed into the same AST property. Fix: add a FreeAnnotation field, classify annotations before activity metadata as free-floating when later metadata binds the activity, and preserve the free note when building the microflow graph. Tests: added parser and builder coverage for free-versus-attached annotation order, documented the feature with a draft proposal, quick-reference entry, skill guidance, and a doctype fixture checked with mxcli. --- .claude/skills/mendix/write-microflows.md | 1 + docs/01-project/MDL_QUICK_REFERENCE.md | 1 + .../PROPOSAL_microflow_free_annotation.md | 59 ++++++++++++++++++ docs/11-proposals/README.md | 1 + .../doctype-tests/free_annotation.test.mdl | 14 +++++ mdl/ast/ast_microflow.go | 1 + .../cmd_microflows_builder_annotations.go | 3 + ...cmd_microflows_builder_annotations_test.go | 30 +++++++++ mdl/executor/cmd_microflows_builder_graph.go | 7 +++ mdl/visitor/visitor_microflow_statements.go | 25 +++++++- mdl/visitor/visitor_test.go | 62 +++++++++++++++++++ 11 files changed, 202 insertions(+), 2 deletions(-) create mode 100644 docs/11-proposals/PROPOSAL_microflow_free_annotation.md create mode 100644 mdl-examples/doctype-tests/free_annotation.test.mdl diff --git a/.claude/skills/mendix/write-microflows.md b/.claude/skills/mendix/write-microflows.md index 5df55b7c..a7c7e112 100644 --- a/.claude/skills/mendix/write-microflows.md +++ b/.claude/skills/mendix/write-microflows.md @@ -559,6 +559,7 @@ commit $Product; **Rules:** - `@annotation` before an activity attaches the note to that activity +- `@annotation` before activity-binding metadata such as `@position`, `@caption`, `@color`, `@excluded`, or `@anchor` stays free-floating when later metadata binds the following activity - `@annotation` at the end (no following activity) creates a free-floating note - Escape single quotes by doubling: `@annotation 'Don''t forget'` - `@position` always appears in DESCRIBE output; `@caption` only when custom; `@color` only when not Default diff --git a/docs/01-project/MDL_QUICK_REFERENCE.md b/docs/01-project/MDL_QUICK_REFERENCE.md index 3733d30e..548909d2 100644 --- a/docs/01-project/MDL_QUICK_REFERENCE.md +++ b/docs/01-project/MDL_QUICK_REFERENCE.md @@ -231,6 +231,7 @@ authentication basic, session | Caption | `@caption 'text'` | Custom caption (before activity) | | Color | `@color Green` | Background color (before activity) | | Annotation | `@annotation 'text'` | Visual note attached to next activity | +| Free annotation | `@annotation 'text'` before `@position(...)` | Free-floating visual note preserved by order | | IF | `if condition then ... [else ...] end if;` | | | LOOP | `loop $item in $list begin ... end loop;` | FOR EACH over list | | WHILE | `while condition begin ... end while;` | Condition-based loop | diff --git a/docs/11-proposals/PROPOSAL_microflow_free_annotation.md b/docs/11-proposals/PROPOSAL_microflow_free_annotation.md new file mode 100644 index 00000000..2321a32b --- /dev/null +++ b/docs/11-proposals/PROPOSAL_microflow_free_annotation.md @@ -0,0 +1,59 @@ +# Microflow Free Annotation + +Status: Draft + +## Summary + +Add explicit round-trip support for free-floating microflow annotations that are +serialized next to an activity in MDL but are not visually attached to that +activity in the Mendix model. + +## Motivation + +Mendix stores some visual notes as standalone annotations. During `describe`, +those notes can appear immediately before an activity because the activity is +the next stable textual anchor. If the parser treats every preceding +`@annotation` as activity metadata, `exec` rewrites the note into an attached +annotation flow and changes the diagram. + +## Semantics + +`@annotation 'text'` is treated as a free annotation when both conditions hold: + +1. It appears before any activity-binding metadata for the same statement. +2. A later activity-binding annotation follows before the activity statement. + +Activity-binding metadata is currently `@position`, `@caption`, `@color`, +`@excluded`, or `@anchor`. + +Example: + +```mdl +@annotation 'section header' +@position(100, 200) +log info node 'Audit' 'starting'; +``` + +The note remains free-floating. By contrast, an annotation after `@position` is +still attached to the activity: + +```mdl +@position(100, 200) +@annotation 'activity note' +log info node 'Audit' 'starting'; +``` + +## Tests And Examples + +- `mdl-examples/doctype-tests/free_annotation.test.mdl` documents the supported + syntax. +- Parser tests cover both order-sensitive cases. +- Builder tests verify that the free annotation is emitted as a standalone + annotation and not attached to the activity. + +## Open Questions + +- Should free annotation binding use textual order only, or should it also + consider visual proximity in the microflow diagram? +- Should MDL grow an explicit keyword for free annotations to avoid relying on + order-sensitive disambiguation? diff --git a/docs/11-proposals/README.md b/docs/11-proposals/README.md index 3f997476..d34bed5f 100644 --- a/docs/11-proposals/README.md +++ b/docs/11-proposals/README.md @@ -43,6 +43,7 @@ BSON schema Registry ◄──── multi-version Support |----------|--------|---------|------------| | [MDL Syntax Improvements v1](PROPOSAL_mdl_syntax_improvements.md) | Draft | Go-style assignment, C-style braces, fluent list APIs | — | | [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 | +| [Microflow Free Annotation](PROPOSAL_microflow_free_annotation.md) | Draft | Order-sensitive `@annotation` handling for free-floating visual notes in microflows | — | | [Page Syntax V2](PROPOSAL_page_syntax_v2.md) | Superseded | Page/widget syntax with `{}` blocks and `->` binding. Superseded by V3 (archived) | — | | [Page Styling Support](page-styling-support.md) | Partial | CSS classes, inline styles, dynamic classes, design properties. Phase 1 (Class/Style) done | — | | [Page Composition](proposal_page_composition.md) | Proposed | Fragment definitions and ALTER PAGE for partial page editing | Page Syntax V2, Page Styling | diff --git a/mdl-examples/doctype-tests/free_annotation.test.mdl b/mdl-examples/doctype-tests/free_annotation.test.mdl new file mode 100644 index 00000000..d042fdcf --- /dev/null +++ b/mdl-examples/doctype-tests/free_annotation.test.mdl @@ -0,0 +1,14 @@ +create microflow SampleAnnotations.ACT_FreeAnnotation () +returns Void +begin + @annotation 'section header' + @position(100, 200) + log info node 'Sample' 'start'; + + @position(300, 200) + @annotation 'activity note' + log info node 'Sample' 'attached'; + + return; +end; +/ diff --git a/mdl/ast/ast_microflow.go b/mdl/ast/ast_microflow.go index 7800348d..2be5094f 100644 --- a/mdl/ast/ast_microflow.go +++ b/mdl/ast/ast_microflow.go @@ -132,6 +132,7 @@ type ActivityAnnotations struct { Caption string // @caption 'text' Color string // @color Green AnnotationText string // @annotation 'text' + FreeAnnotation string // @annotation 'text' before @position/@anchor, kept free-floating Excluded bool // @excluded Anchor *FlowAnchors // @anchor(from: X, to: Y) — anchors of the flow leaving this statement diff --git a/mdl/executor/cmd_microflows_builder_annotations.go b/mdl/executor/cmd_microflows_builder_annotations.go index a01afc0d..eda5b011 100644 --- a/mdl/executor/cmd_microflows_builder_annotations.go +++ b/mdl/executor/cmd_microflows_builder_annotations.go @@ -113,6 +113,9 @@ func (fb *flowBuilder) mergeStatementAnnotations(stmt ast.MicroflowStatement) { if ann.AnnotationText != "" { fb.pendingAnnotations.AnnotationText = ann.AnnotationText } + if ann.FreeAnnotation != "" { + fb.pendingAnnotations.FreeAnnotation = ann.FreeAnnotation + } if ann.Anchor != nil { fb.pendingAnnotations.Anchor = ann.Anchor } diff --git a/mdl/executor/cmd_microflows_builder_annotations_test.go b/mdl/executor/cmd_microflows_builder_annotations_test.go index bd471d8d..f53ce1ab 100644 --- a/mdl/executor/cmd_microflows_builder_annotations_test.go +++ b/mdl/executor/cmd_microflows_builder_annotations_test.go @@ -310,3 +310,33 @@ func TestInheritanceSplitCaptionApplied(t *testing.T) { t.Errorf("inheritance split caption: got %q, want %q", split.Caption, "Customer type?") } } + +func TestFreeAnnotationBeforePositionStaysUnattached(t *testing.T) { + body := []ast.MicroflowStatement{ + &ast.LogStmt{ + Level: ast.LogInfo, + Message: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "message"}, + Annotations: &ast.ActivityAnnotations{ + FreeAnnotation: "free synthetic note", + Position: &ast.Position{X: 120, Y: 240}, + }, + }, + } + + fb := &flowBuilder{posX: 100, posY: 100, spacing: HorizontalSpacing} + oc := fb.buildFlowGraph(body, nil) + + freeAnnotations := collectFreeAnnotations(oc) + if len(freeAnnotations) != 1 || freeAnnotations[0] != "free synthetic note" { + t.Fatalf("free annotations = %#v, want one free note", freeAnnotations) + } + + attached := buildAnnotationsByTarget(oc) + for activityID, captions := range attached { + for _, caption := range captions { + if caption == "free synthetic note" { + t.Fatalf("free note was attached to activity %s", activityID) + } + } + } +} diff --git a/mdl/executor/cmd_microflows_builder_graph.go b/mdl/executor/cmd_microflows_builder_graph.go index 603a5213..bea7f935 100644 --- a/mdl/executor/cmd_microflows_builder_graph.go +++ b/mdl/executor/cmd_microflows_builder_graph.go @@ -121,6 +121,9 @@ func (fb *flowBuilder) buildFlowGraph(stmts []ast.MicroflowStatement, returns *a // Handle leftover pending annotations (free-floating annotation text) if fb.pendingAnnotations != nil { + if fb.pendingAnnotations.FreeAnnotation != "" { + fb.attachFreeAnnotation(fb.pendingAnnotations.FreeAnnotation) + } if fb.pendingAnnotations.AnnotationText != "" { fb.attachFreeAnnotation(fb.pendingAnnotations.AnnotationText) } @@ -178,6 +181,10 @@ func (fb *flowBuilder) addStatement(stmt ast.MicroflowStatement) model.ID { fb.posX = fb.pendingAnnotations.Position.X fb.posY = fb.pendingAnnotations.Position.Y } + if fb.pendingAnnotations != nil && fb.pendingAnnotations.FreeAnnotation != "" { + fb.attachFreeAnnotation(fb.pendingAnnotations.FreeAnnotation) + fb.pendingAnnotations.FreeAnnotation = "" + } switch s := stmt.(type) { case *ast.DeclareStmt: diff --git a/mdl/visitor/visitor_microflow_statements.go b/mdl/visitor/visitor_microflow_statements.go index 3ecc322b..fba307c2 100644 --- a/mdl/visitor/visitor_microflow_statements.go +++ b/mdl/visitor/visitor_microflow_statements.go @@ -153,7 +153,8 @@ func extractMicroflowAnnotations(annotations []parser.IAnnotationContext) *ast.A result := &ast.ActivityAnnotations{} hasAny := false - for _, annCtx := range annotations { + seenActivityMetadata := false + for i, annCtx := range annotations { ann := annCtx.(*parser.AnnotationContext) annName := strings.ToLower(ann.AnnotationName().GetText()) @@ -170,6 +171,7 @@ func extractMicroflowAnnotations(annotations []parser.IAnnotationContext) *ast.A hasAny = true } } + seenActivityMetadata = true case "caption": // @caption 'text' — bare annotationValue @@ -180,6 +182,7 @@ func extractMicroflowAnnotations(annotations []parser.IAnnotationContext) *ast.A hasAny = true } } + seenActivityMetadata = true case "color": // @color Green — bare annotationValue (identifier) @@ -190,13 +193,18 @@ func extractMicroflowAnnotations(annotations []parser.IAnnotationContext) *ast.A hasAny = true } } + seenActivityMetadata = true case "annotation": // @annotation 'text' — bare annotationValue if valCtx := ann.AnnotationValue(); valCtx != nil { text := extractAnnotationValueString(valCtx) if text != "" { - result.AnnotationText = text + if !seenActivityMetadata && hasLaterActivityAnnotation(annotations, i+1) { + result.FreeAnnotation = text + } else { + result.AnnotationText = text + } hasAny = true } } @@ -205,6 +213,7 @@ func extractMicroflowAnnotations(annotations []parser.IAnnotationContext) *ast.A // @excluded — no value needed result.Excluded = true hasAny = true + seenActivityMetadata = true case "anchor": // @anchor(from: right, to: left) — simple form for the outgoing flow. @@ -216,6 +225,7 @@ func extractMicroflowAnnotations(annotations []parser.IAnnotationContext) *ast.A parseAnchorAnnotation(params.(*parser.AnnotationParamsContext), result) hasAny = true } + seenActivityMetadata = true } } @@ -225,6 +235,17 @@ func extractMicroflowAnnotations(annotations []parser.IAnnotationContext) *ast.A return result } +func hasLaterActivityAnnotation(annotations []parser.IAnnotationContext, start int) bool { + for _, annCtx := range annotations[start:] { + ann := annCtx.(*parser.AnnotationContext) + switch strings.ToLower(ann.AnnotationName().GetText()) { + case "position", "caption", "color", "excluded", "anchor": + return true + } + } + return false +} + // parseAnchorAnnotation populates Anchor / TrueBranchAnchor / FalseBranchAnchor / // IteratorAnchor / BodyTailAnchor fields on result from the @anchor(...) params. func parseAnchorAnnotation(params *parser.AnnotationParamsContext, result *ast.ActivityAnnotations) { diff --git a/mdl/visitor/visitor_test.go b/mdl/visitor/visitor_test.go index fb2e8be7..809f121f 100644 --- a/mdl/visitor/visitor_test.go +++ b/mdl/visitor/visitor_test.go @@ -1589,3 +1589,65 @@ func TestCalculatedAttributeOnNonPersistentEntity(t *testing.T) { t.Error("Value attribute should be calculated") } } + +func TestAnnotationBeforePositionIsFreeFloating(t *testing.T) { + input := `CREATE MICROFLOW Synthetic.Check () +RETURNS Boolean AS $Success +BEGIN + @annotation 'free note' + @position(100, 200) + LOG INFO NODE 'SyntheticLog' 'message'; + RETURN true; +END;` + + prog, errs := Build(input) + if len(errs) > 0 { + t.Fatalf("unexpected parse errors: %v", errs) + } + + stmt := prog.Statements[0].(*ast.CreateMicroflowStmt) + logStmt, ok := stmt.Body[0].(*ast.LogStmt) + if !ok { + t.Fatalf("Expected LogStmt, got %T", stmt.Body[0]) + } + if logStmt.Annotations == nil { + t.Fatal("expected annotations") + } + if logStmt.Annotations.FreeAnnotation != "free note" { + t.Fatalf("free annotation = %q, want free note", logStmt.Annotations.FreeAnnotation) + } + if logStmt.Annotations.AnnotationText != "" { + t.Fatalf("attached annotation = %q, want empty", logStmt.Annotations.AnnotationText) + } +} + +func TestAnnotationAfterPositionStaysAttached(t *testing.T) { + input := `CREATE MICROFLOW Synthetic.Check () +RETURNS Boolean AS $Success +BEGIN + @position(100, 200) + @annotation 'attached note' + LOG INFO NODE 'SyntheticLog' 'message'; + RETURN true; +END;` + + prog, errs := Build(input) + if len(errs) > 0 { + t.Fatalf("unexpected parse errors: %v", errs) + } + + stmt := prog.Statements[0].(*ast.CreateMicroflowStmt) + logStmt, ok := stmt.Body[0].(*ast.LogStmt) + if !ok { + t.Fatalf("Expected LogStmt, got %T", stmt.Body[0]) + } + if logStmt.Annotations == nil { + t.Fatal("expected annotations") + } + if logStmt.Annotations.AnnotationText != "attached note" { + t.Fatalf("attached annotation = %q, want attached note", logStmt.Annotations.AnnotationText) + } + if logStmt.Annotations.FreeAnnotation != "" { + t.Fatalf("free annotation = %q, want empty", logStmt.Annotations.FreeAnnotation) + } +} From 98729a44e34f4910c6ff917b3881044141c96e61 Mon Sep 17 00:00:00 2001 From: Henrique Costa Date: Sun, 26 Apr 2026 23:56:57 +0200 Subject: [PATCH 2/3] test: cover free annotation describe path The PR review questioned whether free microflow annotations were preserved when reading an existing model back through DESCRIBE. That path does not use ActivityAnnotations: parsed Microflows$Annotation objects and AnnotationFlow links are already split by collectFreeAnnotations and buildAnnotationsByTarget. Extract the free-annotation prefixing into a small helper and add a regression test that starts from a model object collection with one standalone note and one attached note. Tests: - go test ./mdl/executor -run 'TestPrependFreeAnnotationLines_ModelAnnotationsStayFree|TestFreeAnnotationBeforePositionStaysUnattached' - make build - make test - make lint-go - make test-integration --- mdl/executor/cmd_microflows_show.go | 18 +------- mdl/executor/cmd_microflows_show_helpers.go | 13 ++++++ .../cmd_microflows_show_helpers_test.go | 44 +++++++++++++++++++ 3 files changed, 59 insertions(+), 16 deletions(-) diff --git a/mdl/executor/cmd_microflows_show.go b/mdl/executor/cmd_microflows_show.go index cdd9486d..b2b584b4 100644 --- a/mdl/executor/cmd_microflows_show.go +++ b/mdl/executor/cmd_microflows_show.go @@ -276,14 +276,7 @@ func describeMicroflow(ctx *ExecContext, name ast.QualifiedName) error { // Generate activities if targetMf.ObjectCollection != nil && len(targetMf.ObjectCollection.Objects) > 0 { activityLines := formatMicroflowActivities(ctx, targetMf, entityNames, microflowNames) - freeAnnots := collectFreeAnnotations(targetMf.ObjectCollection) - if len(freeAnnots) > 0 && len(activityLines) > 0 { - prefix := make([]string, 0, len(freeAnnots)) - for _, text := range freeAnnots { - prefix = append(prefix, fmt.Sprintf("@annotation %s", mdlQuote(text))) - } - activityLines = append(prefix, activityLines...) - } + activityLines = prependFreeAnnotationLines(targetMf.ObjectCollection, activityLines) for _, line := range activityLines { lines = append(lines, " "+line) } @@ -541,14 +534,7 @@ func renderMicroflowMDL( } else { activityLines = formatMicroflowActivities(ctx, mf, entityNames, microflowNames) } - freeAnnots := collectFreeAnnotations(mf.ObjectCollection) - if len(freeAnnots) > 0 && len(activityLines) > 0 { - prefix := make([]string, 0, len(freeAnnots)) - for _, text := range freeAnnots { - prefix = append(prefix, fmt.Sprintf("@annotation %s", mdlQuote(text))) - } - activityLines = append(prefix, activityLines...) - } + activityLines = prependFreeAnnotationLines(mf.ObjectCollection, activityLines) for _, line := range activityLines { lines = append(lines, " "+line) } diff --git a/mdl/executor/cmd_microflows_show_helpers.go b/mdl/executor/cmd_microflows_show_helpers.go index 6c646abb..0b39ec9a 100644 --- a/mdl/executor/cmd_microflows_show_helpers.go +++ b/mdl/executor/cmd_microflows_show_helpers.go @@ -61,6 +61,19 @@ func collectFreeAnnotations(oc *microflows.MicroflowObjectCollection) []string { return result } +func prependFreeAnnotationLines(oc *microflows.MicroflowObjectCollection, activityLines []string) []string { + freeAnnots := collectFreeAnnotations(oc) + if len(freeAnnots) == 0 || len(activityLines) == 0 { + return activityLines + } + + prefix := make([]string, 0, len(freeAnnots)) + for _, text := range freeAnnots { + prefix = append(prefix, fmt.Sprintf("@annotation %s", mdlQuote(text))) + } + return append(prefix, activityLines...) +} + // anchorSideKeyword returns the MDL keyword (top/right/bottom/left) for a // connection-index value. Returns "" for unknown values. func anchorSideKeyword(idx int) string { diff --git a/mdl/executor/cmd_microflows_show_helpers_test.go b/mdl/executor/cmd_microflows_show_helpers_test.go index 49768902..9c762249 100644 --- a/mdl/executor/cmd_microflows_show_helpers_test.go +++ b/mdl/executor/cmd_microflows_show_helpers_test.go @@ -160,6 +160,50 @@ func TestEmitObjectAnnotations_EscapesMultilineText(t *testing.T) { } } +func TestPrependFreeAnnotationLines_ModelAnnotationsStayFree(t *testing.T) { + oc := µflows.MicroflowObjectCollection{ + Objects: []microflows.MicroflowObject{ + µflows.Annotation{ + BaseMicroflowObject: mkObj("free-note"), + Caption: "free synthetic note", + }, + µflows.Annotation{ + BaseMicroflowObject: mkObj("attached-note"), + Caption: "attached synthetic note", + }, + }, + AnnotationFlows: []*microflows.AnnotationFlow{ + { + BaseElement: model.BaseElement{ID: mkID("annotation-flow")}, + OriginID: mkID("attached-note"), + DestinationID: mkID("activity"), + }, + }, + } + + activityLines := []string{ + "@position(100, 200)", + "@annotation 'attached synthetic note'", + "log info 'Synthetic' 'message';", + } + + gotLines := prependFreeAnnotationLines(oc, activityLines) + got := strings.Join(gotLines, "\n") + + want := strings.Join([]string{ + "@annotation 'free synthetic note'", + "@position(100, 200)", + "@annotation 'attached synthetic note'", + "log info 'Synthetic' 'message';", + }, "\n") + if got != want { + t.Fatalf("free annotation describe output mismatch\nwant:\n%s\n\ngot:\n%s", want, got) + } + if strings.Count(got, "attached synthetic note") != 1 { + t.Fatalf("attached annotation was emitted as free too:\n%s", got) + } +} + // ============================================================================= // formatErrorHandlingSuffix // ============================================================================= From 45ca8e7bd5faa21a18bb1f1bf3198b47b425a876 Mon Sep 17 00:00:00 2001 From: Henrique Costa Date: Mon, 27 Apr 2026 19:47:49 +0200 Subject: [PATCH 3/3] fix: preserve multiple free microflow annotations Symptom: when several free @annotation lines appeared before the first activity metadata annotation, only one could survive the parse/build path. Root cause: ActivityAnnotations stored a single FreeAnnotation string. Parsing a second free annotation overwrote the first, and the builder had no ordered list to recreate multiple unattached annotations. Fix: add an ordered FreeAnnotations slice while keeping the existing single-field compatibility path, append free annotations in source order, and emit each one as an unattached microflow annotation during graph construction. Tests: added visitor and builder regressions for multiple free annotations; ran make build, make lint-go, and make test. --- mdl/ast/ast_microflow.go | 15 ++++---- .../cmd_microflows_builder_annotations.go | 23 +++++++++++-- ...cmd_microflows_builder_annotations_test.go | 27 +++++++++++++++ mdl/executor/cmd_microflows_builder_graph.go | 11 +++--- mdl/visitor/visitor_microflow_statements.go | 5 ++- mdl/visitor/visitor_test.go | 34 +++++++++++++++++++ 6 files changed, 101 insertions(+), 14 deletions(-) diff --git a/mdl/ast/ast_microflow.go b/mdl/ast/ast_microflow.go index 2be5094f..4d7f619b 100644 --- a/mdl/ast/ast_microflow.go +++ b/mdl/ast/ast_microflow.go @@ -128,13 +128,14 @@ type FlowAnchors struct { // ActivityAnnotations holds metadata annotations for microflow activities. // These are emitted as @position, @caption, @color, @annotation, @excluded, @anchor lines in MDL. type ActivityAnnotations struct { - Position *Position // @position(x, y) - Caption string // @caption 'text' - Color string // @color Green - AnnotationText string // @annotation 'text' - FreeAnnotation string // @annotation 'text' before @position/@anchor, kept free-floating - Excluded bool // @excluded - Anchor *FlowAnchors // @anchor(from: X, to: Y) — anchors of the flow leaving this statement + Position *Position // @position(x, y) + Caption string // @caption 'text' + Color string // @color Green + AnnotationText string // @annotation 'text' + FreeAnnotation string // @annotation 'text' before @position/@anchor, kept free-floating + FreeAnnotations []string // Multiple free-floating @annotation lines in source order + Excluded bool // @excluded + Anchor *FlowAnchors // @anchor(from: X, to: Y) — anchors of the flow leaving this statement // Split-specific anchors for IF statements. When the statement is not an // IF these remain nil. The grammar accepts them on IfStmt only: diff --git a/mdl/executor/cmd_microflows_builder_annotations.go b/mdl/executor/cmd_microflows_builder_annotations.go index eda5b011..07115bbe 100644 --- a/mdl/executor/cmd_microflows_builder_annotations.go +++ b/mdl/executor/cmd_microflows_builder_annotations.go @@ -113,8 +113,11 @@ func (fb *flowBuilder) mergeStatementAnnotations(stmt ast.MicroflowStatement) { if ann.AnnotationText != "" { fb.pendingAnnotations.AnnotationText = ann.AnnotationText } - if ann.FreeAnnotation != "" { - fb.pendingAnnotations.FreeAnnotation = ann.FreeAnnotation + if texts := freeAnnotationTexts(ann); len(texts) > 0 { + fb.pendingAnnotations.FreeAnnotations = append(fb.pendingAnnotations.FreeAnnotations, texts...) + if fb.pendingAnnotations.FreeAnnotation == "" { + fb.pendingAnnotations.FreeAnnotation = texts[0] + } } if ann.Anchor != nil { fb.pendingAnnotations.Anchor = ann.Anchor @@ -272,3 +275,19 @@ func (fb *flowBuilder) attachFreeAnnotation(text string) { } fb.objects = append(fb.objects, annotation) } + +func freeAnnotationTexts(ann *ast.ActivityAnnotations) []string { + if ann == nil { + return nil + } + texts := append([]string(nil), ann.FreeAnnotations...) + if ann.FreeAnnotation != "" { + for _, text := range texts { + if text == ann.FreeAnnotation { + return texts + } + } + texts = append(texts, ann.FreeAnnotation) + } + return texts +} diff --git a/mdl/executor/cmd_microflows_builder_annotations_test.go b/mdl/executor/cmd_microflows_builder_annotations_test.go index f53ce1ab..718e644d 100644 --- a/mdl/executor/cmd_microflows_builder_annotations_test.go +++ b/mdl/executor/cmd_microflows_builder_annotations_test.go @@ -340,3 +340,30 @@ func TestFreeAnnotationBeforePositionStaysUnattached(t *testing.T) { } } } + +func TestMultipleFreeAnnotationsBeforePositionStayUnattached(t *testing.T) { + body := []ast.MicroflowStatement{ + &ast.LogStmt{ + Level: ast.LogInfo, + Message: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "message"}, + Annotations: &ast.ActivityAnnotations{ + FreeAnnotations: []string{"first free note", "second free note"}, + Position: &ast.Position{X: 120, Y: 240}, + }, + }, + } + + fb := &flowBuilder{posX: 100, posY: 100, spacing: HorizontalSpacing} + oc := fb.buildFlowGraph(body, nil) + + freeAnnotations := collectFreeAnnotations(oc) + want := []string{"first free note", "second free note"} + if len(freeAnnotations) != len(want) { + t.Fatalf("free annotations = %#v, want %#v", freeAnnotations, want) + } + for i, wantText := range want { + if freeAnnotations[i] != wantText { + t.Fatalf("free annotation %d = %q, want %q", i, freeAnnotations[i], wantText) + } + } +} diff --git a/mdl/executor/cmd_microflows_builder_graph.go b/mdl/executor/cmd_microflows_builder_graph.go index bea7f935..2a77d429 100644 --- a/mdl/executor/cmd_microflows_builder_graph.go +++ b/mdl/executor/cmd_microflows_builder_graph.go @@ -121,8 +121,8 @@ func (fb *flowBuilder) buildFlowGraph(stmts []ast.MicroflowStatement, returns *a // Handle leftover pending annotations (free-floating annotation text) if fb.pendingAnnotations != nil { - if fb.pendingAnnotations.FreeAnnotation != "" { - fb.attachFreeAnnotation(fb.pendingAnnotations.FreeAnnotation) + for _, text := range freeAnnotationTexts(fb.pendingAnnotations) { + fb.attachFreeAnnotation(text) } if fb.pendingAnnotations.AnnotationText != "" { fb.attachFreeAnnotation(fb.pendingAnnotations.AnnotationText) @@ -181,9 +181,12 @@ func (fb *flowBuilder) addStatement(stmt ast.MicroflowStatement) model.ID { fb.posX = fb.pendingAnnotations.Position.X fb.posY = fb.pendingAnnotations.Position.Y } - if fb.pendingAnnotations != nil && fb.pendingAnnotations.FreeAnnotation != "" { - fb.attachFreeAnnotation(fb.pendingAnnotations.FreeAnnotation) + if fb.pendingAnnotations != nil { + for _, text := range freeAnnotationTexts(fb.pendingAnnotations) { + fb.attachFreeAnnotation(text) + } fb.pendingAnnotations.FreeAnnotation = "" + fb.pendingAnnotations.FreeAnnotations = nil } switch s := stmt.(type) { diff --git a/mdl/visitor/visitor_microflow_statements.go b/mdl/visitor/visitor_microflow_statements.go index fba307c2..cbc9abbc 100644 --- a/mdl/visitor/visitor_microflow_statements.go +++ b/mdl/visitor/visitor_microflow_statements.go @@ -201,7 +201,10 @@ func extractMicroflowAnnotations(annotations []parser.IAnnotationContext) *ast.A text := extractAnnotationValueString(valCtx) if text != "" { if !seenActivityMetadata && hasLaterActivityAnnotation(annotations, i+1) { - result.FreeAnnotation = text + result.FreeAnnotations = append(result.FreeAnnotations, text) + if result.FreeAnnotation == "" { + result.FreeAnnotation = text + } } else { result.AnnotationText = text } diff --git a/mdl/visitor/visitor_test.go b/mdl/visitor/visitor_test.go index 809f121f..bfe9e1f9 100644 --- a/mdl/visitor/visitor_test.go +++ b/mdl/visitor/visitor_test.go @@ -1621,6 +1621,40 @@ END;` } } +func TestMultipleAnnotationsBeforePositionStayFreeFloating(t *testing.T) { + input := `CREATE MICROFLOW Synthetic.Check () +RETURNS Boolean AS $Success +BEGIN + @annotation 'first free note' + @annotation 'second free note' + @annotation 'third free note' + @position(100, 200) + LOG INFO NODE 'SyntheticLog' 'message'; + RETURN true; +END;` + + prog, errs := Build(input) + if len(errs) > 0 { + t.Fatalf("unexpected parse errors: %v", errs) + } + + stmt := prog.Statements[0].(*ast.CreateMicroflowStmt) + logStmt, ok := stmt.Body[0].(*ast.LogStmt) + if !ok { + t.Fatalf("Expected LogStmt, got %T", stmt.Body[0]) + } + if logStmt.Annotations == nil { + t.Fatal("expected annotations") + } + want := []string{"first free note", "second free note", "third free note"} + if got := logStmt.Annotations.FreeAnnotations; len(got) != len(want) || got[0] != want[0] || got[1] != want[1] || got[2] != want[2] { + t.Fatalf("free annotations = %#v, want %#v", got, want) + } + if logStmt.Annotations.AnnotationText != "" { + t.Fatalf("attached annotation = %q, want empty", logStmt.Annotations.AnnotationText) + } +} + func TestAnnotationAfterPositionStaysAttached(t *testing.T) { input := `CREATE MICROFLOW Synthetic.Check () RETURNS Boolean AS $Success