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..4d7f619b 100644 --- a/mdl/ast/ast_microflow.go +++ b/mdl/ast/ast_microflow.go @@ -128,12 +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' - 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 a01afc0d..07115bbe 100644 --- a/mdl/executor/cmd_microflows_builder_annotations.go +++ b/mdl/executor/cmd_microflows_builder_annotations.go @@ -113,6 +113,12 @@ 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 ann.Anchor != nil { fb.pendingAnnotations.Anchor = ann.Anchor } @@ -269,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 bd471d8d..718e644d 100644 --- a/mdl/executor/cmd_microflows_builder_annotations_test.go +++ b/mdl/executor/cmd_microflows_builder_annotations_test.go @@ -310,3 +310,60 @@ 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) + } + } + } +} + +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 603a5213..2a77d429 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 { + for _, text := range freeAnnotationTexts(fb.pendingAnnotations) { + fb.attachFreeAnnotation(text) + } if fb.pendingAnnotations.AnnotationText != "" { fb.attachFreeAnnotation(fb.pendingAnnotations.AnnotationText) } @@ -178,6 +181,13 @@ 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 { + for _, text := range freeAnnotationTexts(fb.pendingAnnotations) { + fb.attachFreeAnnotation(text) + } + fb.pendingAnnotations.FreeAnnotation = "" + fb.pendingAnnotations.FreeAnnotations = nil + } switch s := stmt.(type) { case *ast.DeclareStmt: 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 // ============================================================================= diff --git a/mdl/visitor/visitor_microflow_statements.go b/mdl/visitor/visitor_microflow_statements.go index 3ecc322b..cbc9abbc 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,21 @@ 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.FreeAnnotations = append(result.FreeAnnotations, text) + if result.FreeAnnotation == "" { + result.FreeAnnotation = text + } + } else { + result.AnnotationText = text + } hasAny = true } } @@ -205,6 +216,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 +228,7 @@ func extractMicroflowAnnotations(annotations []parser.IAnnotationContext) *ast.A parseAnchorAnnotation(params.(*parser.AnnotationParamsContext), result) hasAny = true } + seenActivityMetadata = true } } @@ -225,6 +238,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..bfe9e1f9 100644 --- a/mdl/visitor/visitor_test.go +++ b/mdl/visitor/visitor_test.go @@ -1589,3 +1589,99 @@ 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 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 +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) + } +}