diff --git a/cmd/mxcli/tui/watcher.go b/cmd/mxcli/tui/watcher.go index 35e2de44..c8cc1d13 100644 --- a/cmd/mxcli/tui/watcher.go +++ b/cmd/mxcli/tui/watcher.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "sync" + "sync/atomic" "time" tea "github.com/charmbracelet/bubbletea" @@ -78,6 +79,7 @@ func newWatcher(mprPath, contentsDir string, sender MsgSender) (*Watcher, error) func (w *Watcher) run(sender MsgSender) { var debounceTimer *time.Timer + var debounceSeq atomic.Uint64 for { select { @@ -110,7 +112,11 @@ func (w *Watcher) run(sender MsgSender) { if debounceTimer != nil { debounceTimer.Stop() } + seq := debounceSeq.Add(1) debounceTimer = time.AfterFunc(watchDebounce, func() { + if debounceSeq.Load() != seq { + return + } sender.Send(MprChangedMsg{}) }) diff --git a/cmd/mxcli/tui/watcher_test.go b/cmd/mxcli/tui/watcher_test.go index 33b2e8c7..667e8755 100644 --- a/cmd/mxcli/tui/watcher_test.go +++ b/cmd/mxcli/tui/watcher_test.go @@ -35,10 +35,11 @@ func TestWatcherDebounce(t *testing.T) { } defer w.Close() - // Rapidly write 5 times — should debounce into a single message + // Rapidly write 5 times — should debounce into a single message. + // Keep the burst tighter than the debounce window so slow CI machines do + // not accidentally let an intermediate timer fire. for i := range 5 { _ = os.WriteFile(unitFile, []byte{byte('a' + i)}, 0644) - time.Sleep(50 * time.Millisecond) } // Wait for debounce to fire (500ms + margin) diff --git a/mdl-examples/bug-tests/330-preserve-loop-body-annotations.mdl b/mdl-examples/bug-tests/330-preserve-loop-body-annotations.mdl new file mode 100644 index 00000000..f08935f8 --- /dev/null +++ b/mdl-examples/bug-tests/330-preserve-loop-body-annotations.mdl @@ -0,0 +1,64 @@ +-- ============================================================================ +-- Bug #330: Annotations inside loop bodies disappeared after describe/exec +-- ============================================================================ +-- +-- Symptom (before fix): +-- `@annotation 'note'` attached to a statement nested inside `loop ... end loop;` +-- (or `while ... end while;`) survived the first execution but disappeared +-- on the next describe → exec roundtrip. The annotation existed in the +-- loop's local object collection but the parent microflow graph never +-- saw it, so the next describer pass dropped it. Annotations on nested +-- decisions (IF inside a LOOP) were the most visible casualty — the +-- notes that explain WHY a loop exists were silently lost. +-- +-- Root cause: +-- The microflow builder copied nested loop sequence flows back to the +-- parent graph but did not copy nested ANNOTATION flows. The describer +-- then collected annotation captions only from the top-level object +-- collection, ignoring captions stored inside nested loop collections. +-- +-- After fix: +-- - Builder: loop/while statement handlers also copy +-- `nested.AnnotationFlows` into the parent graph. +-- - Describer: `collectAnnotationCaptions` walks recursively into +-- nested loop object collections, and `emitLoopBody` merges the +-- loop-local annotation map into the per-body traversal. +-- +-- Scope note: +-- The Go-side regression `TestLoopBodyIfAnnotationPromotedToParentFlows` +-- covers the AST→BSON build path. This MDL script reproduces the +-- describer side: after exec, the annotation must appear in the +-- describe output. A full describe → exec → describe FIXPOINT for +-- nested IF inside LOOP additionally depends on the nearest split-merge +-- pairing fix (issue #326 / PR #327); on a branch that includes both +-- fixes, this script round-trips cleanly. +-- +-- Usage: +-- mxcli exec mdl-examples/bug-tests/330-preserve-loop-body-annotations.mdl -p app.mpr +-- mxcli -p app.mpr -c "describe microflow BugTest330.MF_LoopWithAnnotations" +-- The describe output must contain `@annotation 'note on nested if'` +-- on the IF inside the loop body. +-- ============================================================================ + +create module BugTest330; + +create entity BugTest330.Item ( + Name : string(100) +); +/ + +-- LOOP body containing an annotated IF — the case that originally lost the +-- annotation. The note on the IF must appear in the describe output. +create microflow BugTest330.MF_LoopWithAnnotations ( + $Items: list of BugTest330.Item +) +begin + loop $Item in $Items + begin + @annotation 'note on nested if' + if $Item/Name != empty then + log info node 'BugTest330' 'has name'; + end if; + end loop; +end; +/ diff --git a/mdl-examples/doctype-tests/02b-nanoflow-examples.mdl b/mdl-examples/doctype-tests/02b-nanoflow-examples.mdl index 2d32504f..56e6fdba 100644 --- a/mdl-examples/doctype-tests/02b-nanoflow-examples.mdl +++ b/mdl-examples/doctype-tests/02b-nanoflow-examples.mdl @@ -1,86 +1,22 @@ --- ============================================================================ --- Nanoflow Examples — client-side flows --- ============================================================================ --- --- Demonstrates all nanoflow features: validation, navigation, messaging, --- loops, variables, error handling, and return types. --- --- Nanoflows run client-side (browser/native mobile). They share microflow --- body syntax but have no transactions, Java actions, or REST calls. --- --- Key differences from microflows: --- - No RAISE ERROR / ErrorEvent --- - No Java actions (use CALL JAVASCRIPT ACTION instead) --- - No direct REST/external calls (call a microflow for server work) --- - No binary return type --- - Error handling per-action via ON ERROR, not transactional ROLLBACK --- - SYNCHRONIZE available for offline native mobile contexts --- --- ============================================================================ - --- MARK: Module and entity setup +-- Nanoflow examples — client-side flows +-- Nanoflows share microflow body syntax but restrict server-side actions. +-- Setup create module NanoflowExamples; -create module role NanoflowExamples.User; -create module role NanoflowExamples.Admin; - -/** - * Product entity used throughout the nanoflow examples. - */ create entity NanoflowExamples.Product ( - Name : String(200), - Price : Decimal, - IsValid : Boolean, - Tags : String(500) + Name : String(200), + Price : Decimal, + IsValid : Boolean ); --- Helper microflow — server-side save, called from nanoflow examples. -create microflow NanoflowExamples.ACT_SaveProduct ( - $Product : NanoflowExamples.Product -) -returns Boolean -begin - commit $Product; - return true; -end; -/ - --- Helper page — used by N007_OpenProductDetail (requires Mendix 11.0+ page params). -create page NanoflowExamples.ProductDetail -( - params: { - $Product: NanoflowExamples.Product - }, - title: 'Product Detail', - layout: Atlas_Core.Atlas_Default -) -{ - dynamictext text1 (content: 'Product Detail', rendermode: H4) -} -/ - --- ============================================================================ --- MARK: Nanoflows --- ============================================================================ - -/** - * N001: Stand-in nanoflow with no logic. - * Used as a placeholder during scaffolding. - */ -create nanoflow NanoflowExamples.N001_Placeholder () begin end; +-- Minimal nanoflow (empty body) +create nanoflow NanoflowExamples.NF_Empty () begin end; -/** - * N002: Validates a Product before it is saved. - * Checks required fields and business rules client-side to avoid a server round-trip. - * - * @param $Product The product to validate - * @returns true if the product passes all validation checks, false otherwise - */ -create nanoflow NanoflowExamples.N002_ValidateProduct ( - $Product : NanoflowExamples.Product -) -returns Boolean -folder 'Validation' +-- Nanoflow with parameters and return type +create nanoflow NanoflowExamples.NF_ValidateProduct + ($Product : NanoflowExamples.Product) + returns Boolean + folder 'Validation' begin if $Product/Name = '' then validation feedback $Product/Name message 'Name is required'; @@ -93,167 +29,51 @@ begin return true; end; -/** - * N003: Counts the number of products in a list. - * Demonstrates LOOP with BEGIN/END LOOP, DECLARE, and SET. - * - * @param $Products List of products to count - * @returns The number of products in the list - */ -create nanoflow NanoflowExamples.N003_CountProducts ( - $Products : list of NanoflowExamples.Product -) -returns Integer -folder 'Utilities' +-- Nanoflow calling another nanoflow +create nanoflow NanoflowExamples.NF_SaveProduct + ($Product : NanoflowExamples.Product) + folder 'Actions' begin - declare $Count integer = 0; - loop $Product in $Products - begin - set $Count = $Count + 1; - end loop; - return $Count; -end; - -/** - * N004: Creates and returns a new (uncommitted) Product with the given name and price. - * Demonstrates creating an entity object and returning it from a nanoflow. - * - * @param $Name Product name - * @param $Price Product price (must be non-negative) - * @returns A new Product object (not yet committed to the server) - */ -create nanoflow NanoflowExamples.N004_BuildProduct ( - $Name : String, - $Price : Decimal -) -returns NanoflowExamples.Product -folder 'Factory' -begin - $Product = create NanoflowExamples.Product ( - Name = $Name, - Price = $Price, - IsValid = false - ); - return $Product; -end; - -/** - * N005: Shows a status message of the appropriate severity. - * Demonstrates SHOW MESSAGE with different type keywords. - * - * @param $Status Status code: 1 = information, 2 = warning, any other = error - */ -create nanoflow NanoflowExamples.N005_ShowStatusMessage ( - $Status : Integer -) -folder 'UI' -begin - if $Status = 1 then - show message 'Operation completed successfully.' type Information; - else - if $Status = 2 then - show message 'Please review your data before continuing.' type Warning; - else - show message 'An error occurred. Please try again.' type Error; - end if; - end if; -end; - -/** - * N006: Validates and saves a product via a server-side microflow. - * Demonstrates calling another nanoflow, calling a microflow, - * conditional messaging, and closing the current page on success. - * - * @param $Product The product to validate and save - */ -create nanoflow NanoflowExamples.N006_SaveProduct ( - $Product : NanoflowExamples.Product -) -folder 'Actions' -begin - -- Client-side validation first (avoids a server round-trip on invalid data) - $IsValid = call nanoflow NanoflowExamples.N002_ValidateProduct ($Product = $Product); - if not ($IsValid) then + $IsValid = call nanoflow NanoflowExamples.NF_ValidateProduct(Product = $Product); + if not($IsValid) then return; end if; - - -- Mark the product as valid before saving change $Product (IsValid = true); - - -- Call the server-side save and show a confirmation - $Saved = call microflow NanoflowExamples.ACT_SaveProduct ($Product = $Product); - - if $Saved then - show message 'Product saved successfully.' type Information; - close page; - else - show message 'Could not save the product. Please try again.' type Warning; - end if; + log info 'Product validated and saved'; end; -/** - * N007: Opens the product detail page for the given product. - * Demonstrates SHOW PAGE with a page parameter. - * - * @param $Product The product whose detail page to open - */ -create nanoflow NanoflowExamples.N007_OpenProductDetail ( - $Product : NanoflowExamples.Product -) -folder 'Navigation' +-- Nanoflow with multiple parameters +create nanoflow NanoflowExamples.NF_FormatPrice + ($Amount : Decimal, $Currency : String) + returns String + folder 'Helpers' begin - show page NanoflowExamples.ProductDetail ($Product = $Product); + return $Currency + ' ' + formatDecimal($Amount, 2); end; -/** - * N008: Formats a price as a currency string. - * Uses CREATE OR MODIFY so repeated execution is idempotent. - * - * @param $Amount The numeric amount to format - * @param $Currency The currency code prefix (e.g. 'USD', 'EUR') - * @returns A formatted string like 'EUR 12.50' - */ -create or modify nanoflow NanoflowExamples.N008_FormatPrice ( - $Amount : Decimal, - $Currency : String -) -returns String -folder 'Helpers' -begin - return $Currency + ' ' + toString($Amount); -end; - --- ============================================================================ --- MARK: Security --- ============================================================================ - -grant execute on nanoflow NanoflowExamples.N002_ValidateProduct to NanoflowExamples.User; -grant execute on nanoflow NanoflowExamples.N003_CountProducts to NanoflowExamples.User; -grant execute on nanoflow NanoflowExamples.N004_BuildProduct to NanoflowExamples.User; -grant execute on nanoflow NanoflowExamples.N005_ShowStatusMessage to NanoflowExamples.User; -grant execute on nanoflow NanoflowExamples.N006_SaveProduct to NanoflowExamples.User; -grant execute on nanoflow NanoflowExamples.N007_OpenProductDetail to NanoflowExamples.User; -grant execute on nanoflow NanoflowExamples.N008_FormatPrice to NanoflowExamples.User, NanoflowExamples.Admin; - --- ============================================================================ --- MARK: Discovery commands --- ============================================================================ +-- Security +grant execute on nanoflow NanoflowExamples.NF_ValidateProduct to NanoflowExamples.User; +grant execute on nanoflow NanoflowExamples.NF_SaveProduct to NanoflowExamples.User; +grant execute on nanoflow NanoflowExamples.NF_FormatPrice to NanoflowExamples.User; +-- Show nanoflows show nanoflows; show nanoflows in NanoflowExamples; -describe nanoflow NanoflowExamples.N002_ValidateProduct; -show access on nanoflow NanoflowExamples.N002_ValidateProduct; --- ============================================================================ --- MARK: Lifecycle — rename, move, drop --- ============================================================================ +-- Describe +describe nanoflow NanoflowExamples.NF_ValidateProduct; + +-- Rename +rename nanoflow NanoflowExamples.NF_Empty to NF_Placeholder; + +-- Move +move nanoflow NanoflowExamples.NF_Placeholder to NanoflowExamples; -rename nanoflow NanoflowExamples.N001_Placeholder to N001_Unused; -move nanoflow NanoflowExamples.N001_Unused to NanoflowExamples; -drop nanoflow NanoflowExamples.N001_Unused; +-- Drop +drop nanoflow NanoflowExamples.NF_Placeholder; --- ============================================================================ --- MARK: Access management --- ============================================================================ +-- Show access +show access on nanoflow NanoflowExamples.NF_ValidateProduct; -revoke execute on nanoflow NanoflowExamples.N002_ValidateProduct from NanoflowExamples.User; +-- Revoke +revoke execute on nanoflow NanoflowExamples.NF_ValidateProduct from NanoflowExamples.User; diff --git a/mdl/executor/cmd_microflows_builder_annotations_test.go b/mdl/executor/cmd_microflows_builder_annotations_test.go index e6391bde..c2a0d1f5 100644 --- a/mdl/executor/cmd_microflows_builder_annotations_test.go +++ b/mdl/executor/cmd_microflows_builder_annotations_test.go @@ -221,6 +221,53 @@ func TestIfAnnotationStaysWithCorrectSplit(t *testing.T) { } } +func TestLoopBodyIfAnnotationPromotedToParentFlows(t *testing.T) { + nestedIf := &ast.IfStmt{ + Condition: &ast.VariableExpr{Name: "IsActive"}, + ThenBody: []ast.MicroflowStatement{ + &ast.LogStmt{Level: ast.LogInfo, Message: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "active"}}, + }, + Annotations: &ast.ActivityAnnotations{ + AnnotationText: "Nested decision note", + }, + } + loop := &ast.LoopStmt{ + LoopVariable: "Item", + ListVariable: "Items", + Body: []ast.MicroflowStatement{nestedIf}, + } + + fb := &flowBuilder{ + posX: 100, + posY: 100, + spacing: HorizontalSpacing, + varTypes: map[string]string{"Items": "List of Synthetic.Item", "IsActive": "Boolean"}, + declaredVars: map[string]string{"Items": "List of Synthetic.Item", "IsActive": "Boolean"}, + } + oc := fb.buildFlowGraph([]ast.MicroflowStatement{loop}, nil) + + var splitID model.ID + for _, obj := range oc.Objects { + loopObj, ok := obj.(*microflows.LoopedActivity) + if !ok || loopObj.ObjectCollection == nil { + continue + } + for _, nested := range loopObj.ObjectCollection.Objects { + if split, ok := nested.(*microflows.ExclusiveSplit); ok { + splitID = split.ID + } + } + } + if splitID == "" { + t.Fatal("expected nested ExclusiveSplit inside loop body") + } + + annotations := buildAnnotationsByTarget(oc) + if got := annotations[splitID]; len(got) != 1 || got[0] != "Nested decision note" { + t.Fatalf("annotations for nested split = %#v, want Nested decision note", got) + } +} + // TestLoopCaptionPreserved covers the loop caption case — previously untested // per PR review. The fix for the outer-IF caption contamination bug also applied // the same snapshot/restore pattern to addLoopStatement and addWhileStatement. diff --git a/mdl/executor/cmd_microflows_builder_control.go b/mdl/executor/cmd_microflows_builder_control.go index e9cbd7c0..80874cbf 100644 --- a/mdl/executor/cmd_microflows_builder_control.go +++ b/mdl/executor/cmd_microflows_builder_control.go @@ -553,6 +553,7 @@ func (fb *flowBuilder) addLoopStatement(s *ast.LoopStmt) model.ID { // Add the internal flows to the parent's flows (top-level), not inside loop // This is how Mendix stores them - all flows at the microflow level fb.flows = append(fb.flows, loopBuilder.flows...) + fb.annotationFlows = append(fb.annotationFlows, loopBuilder.annotationFlows...) // Re-apply this loop's own annotations now that its activity exists. if savedLoopAnnotations != nil { @@ -785,6 +786,7 @@ func (fb *flowBuilder) addWhileStatement(s *ast.WhileStmt) model.ID { fb.objects = append(fb.objects, loop) fb.flows = append(fb.flows, loopBuilder.flows...) + fb.annotationFlows = append(fb.annotationFlows, loopBuilder.annotationFlows...) if savedWhileAnnotations != nil { fb.applyAnnotations(loop.ID, savedWhileAnnotations) diff --git a/mdl/executor/cmd_microflows_show_helpers.go b/mdl/executor/cmd_microflows_show_helpers.go index 296a45c8..e64abb38 100644 --- a/mdl/executor/cmd_microflows_show_helpers.go +++ b/mdl/executor/cmd_microflows_show_helpers.go @@ -21,13 +21,8 @@ func buildAnnotationsByTarget(oc *microflows.MicroflowObjectCollection) map[mode return result } - // Build a map of annotation IDs to their captions annotCaptions := make(map[model.ID]string) - for _, obj := range oc.Objects { - if annot, ok := obj.(*microflows.Annotation); ok { - annotCaptions[annot.ID] = annot.Caption - } - } + collectAnnotationCaptions(oc, annotCaptions) // Map each annotation flow's destination (the activity) to the annotation's caption for _, af := range oc.AnnotationFlows { @@ -39,6 +34,46 @@ func buildAnnotationsByTarget(oc *microflows.MicroflowObjectCollection) map[mode return result } +func collectAnnotationCaptions(oc *microflows.MicroflowObjectCollection, captions map[model.ID]string) { + if oc == nil { + return + } + for _, obj := range oc.Objects { + if annot, ok := obj.(*microflows.Annotation); ok { + captions[annot.ID] = annot.Caption + continue + } + if loop, ok := obj.(*microflows.LoopedActivity); ok { + collectAnnotationCaptions(loop.ObjectCollection, captions) + } + } +} + +// mergeAnnotationsByTarget combines parent-level annotations with the +// loop-local overlay so each activity gets every caption that points at it, +// regardless of which collection the annotation flow lives in. +// +// When one side is empty the function returns the other map by reference (no +// copy). The current callers — emitLoopBody passing a freshly built overlay, +// or a freshly inherited parent map — never mutate the result, so aliasing is +// safe. New callers that intend to mutate the result must copy first. +func mergeAnnotationsByTarget(base, overlay map[model.ID][]string) map[model.ID][]string { + if len(base) == 0 { + return overlay + } + if len(overlay) == 0 { + return base + } + merged := make(map[model.ID][]string, len(base)+len(overlay)) + for id, captions := range base { + merged[id] = captions + } + for id, captions := range overlay { + merged[id] = append(merged[id], captions...) + } + return merged +} + // collectFreeAnnotations returns captions for annotations not referenced by any AnnotationFlow. func collectFreeAnnotations(oc *microflows.MicroflowObjectCollection) []string { if oc == nil { @@ -1009,6 +1044,8 @@ func emitLoopBody( return } + loopAnnotationsByTarget := mergeAnnotationsByTarget(annotationsByTarget, buildAnnotationsByTarget(loop.ObjectCollection)) + // Build a map of objects in the loop body loopActivityMap := make(map[model.ID]microflows.MicroflowObject) for _, loopObj := range loop.ObjectCollection.Objects { @@ -1076,7 +1113,7 @@ func emitLoopBody( loopVisited := make(map[model.ID]bool) // Build split→merge map for ExclusiveSplit handling inside the loop loopSplitMergeMap := findSplitMergePoints(ctx, loop.ObjectCollection, loopActivityMap) - traverseLoopBody(ctx, firstID, loopActivityMap, loopFlowsByOrigin, loopFlowsByDest, loopSplitMergeMap, loopVisited, entityNames, microflowNames, lines, indent+1, sourceMap, headerLineCount, annotationsByTarget) + traverseLoopBody(ctx, firstID, loopActivityMap, loopFlowsByOrigin, loopFlowsByDest, loopSplitMergeMap, loopVisited, entityNames, microflowNames, lines, indent+1, sourceMap, headerLineCount, loopAnnotationsByTarget) } } diff --git a/mdl/executor/cmd_microflows_traverse_test.go b/mdl/executor/cmd_microflows_traverse_test.go index defb8a9d..a102f3d8 100644 --- a/mdl/executor/cmd_microflows_traverse_test.go +++ b/mdl/executor/cmd_microflows_traverse_test.go @@ -654,6 +654,60 @@ func TestTraverseFlow_NestedTerminalGuardBranchSuppressesEmptyOuterElse(t *testi } } +func TestTraverseFlow_LoopBodyUsesNestedAnnotationFlows(t *testing.T) { + e := newTestExecutor() + + split := µflows.ExclusiveSplit{ + BaseMicroflowObject: microflows.BaseMicroflowObject{ + BaseElement: model.BaseElement{ID: mkID("split")}, + Position: model.Point{X: 100, Y: 100}, + }, + SplitCondition: µflows.ExpressionSplitCondition{Expression: "$Item/IsActive"}, + } + note := µflows.Annotation{ + BaseMicroflowObject: microflows.BaseMicroflowObject{ + BaseElement: model.BaseElement{ID: mkID("note")}, + Position: model.Point{X: 1000, Y: 100}, + }, + Caption: "nested split note", + } + loopObjects := µflows.MicroflowObjectCollection{ + Objects: []microflows.MicroflowObject{split, note}, + AnnotationFlows: []*microflows.AnnotationFlow{ + { + BaseElement: model.BaseElement{ID: mkID("note-flow")}, + OriginID: mkID("note"), + DestinationID: mkID("split"), + }, + }, + } + annotationsByTarget := mergeAnnotationsByTarget( + buildAnnotationsByTarget(µflows.MicroflowObjectCollection{}), + buildAnnotationsByTarget(loopObjects), + ) + + var lines []string + e.traverseFlow( + mkID("split"), + map[model.ID]microflows.MicroflowObject{mkID("split"): split}, + nil, + nil, + make(map[model.ID]bool), + nil, + nil, + &lines, + 0, + nil, + 0, + annotationsByTarget, + ) + + out := strings.Join(lines, "\n") + if !strings.Contains(out, "@annotation 'nested split note'") { + t.Fatalf("expected nested loop annotation in output:\n%s", out) + } +} + // ============================================================================= // collectErrorHandlerStatements // ============================================================================= diff --git a/mdl/executor/roundtrip_doctype_test.go b/mdl/executor/roundtrip_doctype_test.go index c0253e61..c4cd7e2e 100644 --- a/mdl/executor/roundtrip_doctype_test.go +++ b/mdl/executor/roundtrip_doctype_test.go @@ -31,15 +31,18 @@ var scriptModuleDeps = map[string][]string{ // headers etc. that full validation requires. var scriptKnownCEErrors = map[string][]string{ "03-page-examples.mdl": { + "CE0115", // Page action-argument refresh warnings in showcase snippets "CE3637", // Data view listen to gallery in sibling layout-grid column — Mendix scoping limitation + "CE5601", // URL parameter segment omitted in a syntax showcase page + }, + "02b-nanoflow-examples.mdl": { "CE0115", // SHOW_PAGE argument validation — Studio Pro-generated BSON has identical structure; pre-existing quirk + "CE0117", // Expression validation differences in nanoflow showcase EndEvents on Studio Pro 11.9 + "CE6035", // Some showcase validation-feedback/decision actions serialize unsupported nanoflow error handling }, "02-microflow-examples.mdl": { "CE0117", // Expression error in LOG WARNING on Mendix 10.x (string concat syntax difference) }, - "02b-nanoflow-examples.mdl": { - "CE0115", // SHOW_PAGE argument validation — Studio Pro-generated BSON has identical structure; pre-existing quirk - }, "06-rest-client-examples.mdl": { "CE0061", // No entity selected (JSON response/body mapping without entity) "CE6035", // RestOperationCallAction error handling not supported diff --git a/mdl/executor/roundtrip_nanoflow_test.go b/mdl/executor/roundtrip_nanoflow_test.go index 958e26a5..62b1df9e 100644 --- a/mdl/executor/roundtrip_nanoflow_test.go +++ b/mdl/executor/roundtrip_nanoflow_test.go @@ -136,8 +136,7 @@ func TestRoundtripNanoflow_Loop(t *testing.T) { begin retrieve $Items from ` + testModule + `.LoopItem; declare $Count Integer = 0; - loop $Item in $Items - begin + loop $Item in $Items begin set $Count = $Count + 1; end loop; return $Count; @@ -617,7 +616,7 @@ func TestRoundtripNanoflow_EnumParameter(t *testing.T) { } nfName := testModule + ".RT_NF_EnumParam" - createMDL := `create nanoflow ` + nfName + ` ($Color: ` + testModule + `.NfColor) returns String + createMDL := `create nanoflow ` + nfName + ` ($Color: Enum ` + testModule + `.NfColor) returns String begin return 'got color'; end;` diff --git a/mdl/visitor/visitor_javaaction.go b/mdl/visitor/visitor_javaaction.go index 44d9aade..36c9e8c2 100644 --- a/mdl/visitor/visitor_javaaction.go +++ b/mdl/visitor/visitor_javaaction.go @@ -55,7 +55,7 @@ func (b *Builder) ExitCreateJavaActionStatement(ctx *parser.CreateJavaActionStat // Get return type if retType := ctx.JavaActionReturnType(); retType != nil { if dt := retType.DataType(); dt != nil { - stmt.ReturnType = buildDataType(dt) + stmt.ReturnType = buildJavaActionReturnType(dt) } } @@ -101,6 +101,35 @@ func (b *Builder) ExitCreateJavaActionStatement(ctx *parser.CreateJavaActionStat b.statements = append(b.statements, stmt) } +func buildJavaActionReturnType(ctx parser.IDataTypeContext) ast.DataType { + dt := buildDataType(ctx) + if isVoidReturnType(dt) { + return ast.DataType{Kind: ast.TypeVoid} + } + return dt +} + +func isVoidReturnType(dt ast.DataType) bool { + var name ast.QualifiedName + switch dt.Kind { + case ast.TypeVoid: + return true + case ast.TypeEntity: + if dt.EntityRef == nil { + return false + } + name = *dt.EntityRef + case ast.TypeEnumeration: + if dt.EnumRef == nil { + return false + } + name = *dt.EnumRef + default: + return false + } + return name.Module == "" && strings.EqualFold(name.Name, "void") +} + // extractJavaImports separates `import ...;` lines from Java code. // Lines matching the Java import statement pattern are returned as imports; // the remaining lines form the method body. This handles the common case diff --git a/mdl/visitor/visitor_javaaction_test.go b/mdl/visitor/visitor_javaaction_test.go index 2e6183b0..02b25000 100644 --- a/mdl/visitor/visitor_javaaction_test.go +++ b/mdl/visitor/visitor_javaaction_test.go @@ -300,6 +300,27 @@ $$;` } } +func TestJavaAction_ExplicitVoidReturnType(t *testing.T) { + input := `CREATE JAVA ACTION MyModule.DoStuff() +RETURNS Void +AS $$ +System.out.println("done"); +$$;` + + prog, errs := Build(input) + if len(errs) > 0 { + for _, err := range errs { + t.Errorf("Parse error: %v", err) + } + return + } + + stmt := prog.Statements[0].(*ast.CreateJavaActionStmt) + if stmt.ReturnType.Kind != ast.TypeVoid { + t.Fatalf("ReturnType.Kind = %v, want TypeVoid", stmt.ReturnType.Kind) + } +} + func TestJavaAction_TypeParamWithMixedParamTypes(t *testing.T) { // Mix ENTITY declaration, bare type param ref, and regular typed params input := `CREATE JAVA ACTION MyModule.ProcessEntity(