diff --git a/mdl-examples/bug-tests/free-annotation-roundtrip.mdl b/mdl-examples/bug-tests/free-annotation-roundtrip.mdl new file mode 100644 index 00000000..ddd81525 --- /dev/null +++ b/mdl-examples/bug-tests/free-annotation-roundtrip.mdl @@ -0,0 +1,31 @@ +-- ============================================================================ +-- Regression: free microflow annotations remain free after roundtrip +-- ============================================================================ +-- +-- Symptom: +-- Free-floating @annotation lines that appear before activity-binding +-- metadata can be attached to the next activity if the AST/builder loses the +-- distinction between free and attached annotations. +-- +-- After fix: +-- Free annotations are represented only by ActivityAnnotations.FreeAnnotations +-- and are emitted as standalone Annotation objects before the activity. +-- +-- Usage: +-- mxcli exec mdl-examples/bug-tests/free-annotation-roundtrip.mdl -p app.mpr +-- mxcli -p app.mpr -c "describe microflow SyntheticFreeAnnotation.MF_FreeAnnotationRoundtrip" +-- The two header annotations should appear before @position and must not be +-- emitted as attached annotations on the log activity. +-- ============================================================================ + +create module SyntheticFreeAnnotation; + +create microflow SyntheticFreeAnnotation.MF_FreeAnnotationRoundtrip () +begin + @annotation 'header note one' + @annotation 'header note two' + @position(100, 200) + @annotation 'attached activity note' + log info node 'Synthetic' 'message'; +end; +/ diff --git a/mdl/ast/ast_microflow.go b/mdl/ast/ast_microflow.go index b611216d..6e0fe3c2 100644 --- a/mdl/ast/ast_microflow.go +++ b/mdl/ast/ast_microflow.go @@ -154,7 +154,6 @@ 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 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 diff --git a/mdl/executor/cmd_microflows_builder_annotations.go b/mdl/executor/cmd_microflows_builder_annotations.go index 9e53aeae..e812ad70 100644 --- a/mdl/executor/cmd_microflows_builder_annotations.go +++ b/mdl/executor/cmd_microflows_builder_annotations.go @@ -119,11 +119,8 @@ func (fb *flowBuilder) mergeStatementAnnotations(stmt ast.MicroflowStatement) { if ann.AnnotationText != "" { fb.pendingAnnotations.AnnotationText = ann.AnnotationText } - 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 len(ann.FreeAnnotations) > 0 { + fb.pendingAnnotations.FreeAnnotations = append(fb.pendingAnnotations.FreeAnnotations, ann.FreeAnnotations...) } if ann.Anchor != nil { fb.pendingAnnotations.Anchor = ann.Anchor @@ -289,19 +286,3 @@ 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 e6391bde..66589fa9 100644 --- a/mdl/executor/cmd_microflows_builder_annotations_test.go +++ b/mdl/executor/cmd_microflows_builder_annotations_test.go @@ -318,8 +318,8 @@ func TestFreeAnnotationBeforePositionStaysUnattached(t *testing.T) { 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}, + FreeAnnotations: []string{"free synthetic note"}, + Position: &ast.Position{X: 120, Y: 240}, }, }, } diff --git a/mdl/executor/cmd_microflows_builder_graph.go b/mdl/executor/cmd_microflows_builder_graph.go index f2c421c7..03e406ca 100644 --- a/mdl/executor/cmd_microflows_builder_graph.go +++ b/mdl/executor/cmd_microflows_builder_graph.go @@ -116,7 +116,10 @@ func (fb *flowBuilder) buildFlowGraph(stmts []ast.MicroflowStatement, returns *a // Handle leftover pending annotations (free-floating annotation text) if fb.pendingAnnotations != nil { - for _, text := range freeAnnotationTexts(fb.pendingAnnotations) { + // Free annotations are standalone Annotation objects. Flush them before + // creating the activity so they do not get attached to it; buildFlowGraph + // has a final leftover flush for annotations with no following activity. + for _, text := range fb.pendingAnnotations.FreeAnnotations { fb.attachFreeAnnotation(text) } if fb.pendingAnnotations.AnnotationText != "" { @@ -428,10 +431,9 @@ func (fb *flowBuilder) addStatement(stmt ast.MicroflowStatement) model.ID { fb.posY = fb.pendingAnnotations.Position.Y } if fb.pendingAnnotations != nil { - for _, text := range freeAnnotationTexts(fb.pendingAnnotations) { + for _, text := range fb.pendingAnnotations.FreeAnnotations { fb.attachFreeAnnotation(text) } - fb.pendingAnnotations.FreeAnnotation = "" fb.pendingAnnotations.FreeAnnotations = nil } diff --git a/mdl/visitor/visitor_microflow_statements.go b/mdl/visitor/visitor_microflow_statements.go index ca788eea..eb5981cd 100644 --- a/mdl/visitor/visitor_microflow_statements.go +++ b/mdl/visitor/visitor_microflow_statements.go @@ -209,9 +209,6 @@ func extractMicroflowAnnotations(annotations []parser.IAnnotationContext) *ast.A if text != "" { if !seenActivityMetadata && hasLaterActivityAnnotation(annotations, i+1) { 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 4012678c..28e396b9 100644 --- a/mdl/visitor/visitor_test.go +++ b/mdl/visitor/visitor_test.go @@ -1613,8 +1613,8 @@ END;` 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 got := logStmt.Annotations.FreeAnnotations; len(got) != 1 || got[0] != "free note" { + t.Fatalf("free annotations = %#v, want [free note]", got) } if logStmt.Annotations.AnnotationText != "" { t.Fatalf("attached annotation = %q, want empty", logStmt.Annotations.AnnotationText) @@ -1681,8 +1681,8 @@ END;` 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) + if len(logStmt.Annotations.FreeAnnotations) != 0 { + t.Fatalf("free annotations = %#v, want empty", logStmt.Annotations.FreeAnnotations) } }