diff --git a/.claude/skills/fix-issue.md b/.claude/skills/fix-issue.md index 6e5e8462d..8c79dc6f0 100644 --- a/.claude/skills/fix-issue.md +++ b/.claude/skills/fix-issue.md @@ -126,6 +126,8 @@ to the symptom table below, so the next similar issue costs fewer reads. | An unrecognized property on a **built-in** (non-pluggable) widget — a typo like `Contnet`, or a genuinely unsupported key — passes `mxcli check`+`exec` with no error and is silently dropped on write (pluggable widgets already got MDL-WIDGET01) | `validateStaticWidget` had no unknown-property check; core widgets have no single property registry — builders read keys imperatively, and `describe` even emits keys (`WidthUnit`) the native builder doesn't consume — so a hard reject would false-positive on valid MDL and break the describe→create roundtrip | `mdl/executor/validate_widgets.go` (`staticWidgetKnownProps`, `validateStaticWidgetUnknownProps`, wired in `validateWidgetTree` gated on `lookupWidgetDef==nil`) | Add **MDL-WIDGET07** as a **WARNING** (never an error — the core-widget vocabulary can't be proven complete): flag any `Properties` key not in `staticWidgetKnownProps` (the union of grammar keyword props + builder-consumed keys + the full `describe`-emit vocabulary, harvested by grep), with a `nearestKey` "did you mean" hint. Runs only for non-pluggable widgets. Guard the allow-list against describe-vocabulary drift with `TestStaticWidgetKnownPropsCoverDescribe`, and sweep `mdl-examples/**` for zero false positives after any change. Bug-test `mdl-examples/bug-tests/widget-unknown-property.mdl`. | | A **quoted** attribute/association name inside an *expression* — a microflow decision/`if`/`while`/`return` (`if $Expense/"Justification" != empty`), a widget `contentparams` entry (`contentparams: [{1} = "Amount"]`), a `visible:`/`editable:` expression (`visible: [ "Amount" > 1000 ]`), or an **XPath database datasource WHERE** (`datasource: database from M.E where [ "Status" = 'Submitted' ]`) — passes `mxcli check` (and `--references`). The first three fail MxBuild "Error(s) in expression"; the **datasource WHERE is the worst variant** — it *also* passes MxBuild, then **silently returns zero rows at runtime** because Mendix reads the quoted `"Status"` as the string literal `'Status'`, making the constraint `'Status' = 'Submitted'` always false. The unquoted form is fine. Sibling of the SET/Change-**target** row above, but for expression contexts (the "always quote identifiers" guidance is parser-safe, not platform-safe here) | Two sub-causes. (a) Expression contexts stored the **raw source text verbatim** — `SourceExpr.Source` from `extractOriginalText` (microflow), `expr.GetText()` (contentparams), the serialized visibility string. (b) The **inline-bracket XPath** path built member names via `buildXPathQualifiedName` (`words[i].GetText()`, no unquote) → an `IdentifierExpr{Name:"\"Status\""}` that `xpathExprToString` re-emits verbatim. Either way the quotes leaked, whereas binding contexts route each identifier through `unquoteIdentifier`. The general (non-XPath) expression AST was already unquoted | `mdl/visitor/visitor_helpers.go` (`stripExpressionIdentifierQuotes`) + `mdl/visitor/visitor_microflow_statements.go` (`buildSourceExpression`, `buildXPathSourceExpression`, `buildRetrieveWhereExpression`) + `mdl/visitor/visitor_page_v3.go` (`buildConditionalExpression`, `buildParamAssignmentV3`) + `mdl/visitor/visitor_xpath.go` (`buildXPathQualifiedName`) | Strip double-quote/backtick identifier quotes at the source. For raw-text slots: `stripExpressionIdentifierQuotes` (tracks single-quote string state incl. `''` escapes, so `'he said "hi"'` is untouched — safe because in Mendix expr/XPath only identifiers are double-quoted). For the inline-bracket XPath AST: `unquoteIdentifier` each `xpathWord` in `buildXPathQualifiedName` (root fix, feeds datasource WHERE + inline retrieve + conditional visibility); guard the `empty`/`true`/`false` special-value switch to fire only on the **unquoted** form so a quoted `"empty"` stays a member name. `dbSource.XPathConstraint = ds.Where` verbatim, so the visitor value is the persisted BSON constraint. Not caught by the expression **type** checker (`PROPOSAL_expression_type_checking.md`) — it works on the clean AST, not the raw string, and is microflow-scoped. Tests: `mdl/visitor/visitor_expr_quoting_test.go`; bug-test `mdl-examples/bug-tests/expr-quoted-identifiers.mdl` | | A `dynamictext`/`datagrid` **contentparams**/**captionparams** entry that navigates an association (`[{1} = Module.Assoc/Attr]`, e.g. `Expense_Employee/Name`) passes `mxcli check --references` but fails MxBuild "Value of text template parameter {1} … No value specified." A **direct** attribute (`[{1} = Title]`) persists fine — the drop is specific to association navigation | The value string (`Module.Assoc/Attr`) reached the AttributeRef serializer, which rejects any path with `< 2` dots (the association hop has 1 dot; `/Attr` is navigation, not counted) → **null AttributeRef** → CE0402. Nothing built the association-step structure Mendix needs. Verified against a Studio-Pro-authored page: the binding is a `DomainModels$AttributeRef` whose `Attribute` is the FINAL attr (`Module.Employee.Name`) and whose `EntityRef` is a `DomainModels$IndirectEntityRef` of `DomainModels$EntityRefStep{Association, DestinationEntity}` hops | `sdk/pages/pages_widgets_display.go` (`ClientTemplateParameter.AttributeRefSteps`, `AttributeRefStep`) + `mdl/executor/cmd_pages_builder_v3.go` (`resolveTemplateAssociationPath`, `associationEndpoints`) + `mdl/backend/modelsdk/widget_write.go` (`attributeRefWithStepsToGen`) + `mdl/executor/cmd_pages_describe_output.go` (`associationTemplateParamPath`) | **modelsdk engine only.** Resolver splits the path into association hop(s) + final attribute, resolves each association's destination entity from the domain model (ParentID=FROM, ChildID=TO; direction from the context entity), and stores `AttributeRef`=final attr QN + `AttributeRefSteps`. The writer emits `AttributeRef.EntityRef` via `entityRefToGen` (reused from microflows — identical `IndirectEntityRef`/`EntityRefStep` storage names); the `< 2 dots` guard no longer trips since the final attr is 2-dot. DESCRIBE reconstructs `Assoc/Attr` from `EntityRef.Steps`. Legacy engine still drops it (out of scope). Generated BSON is byte-structurally identical to Studio Pro. **Non-String final attribute needs NO `toString()`** — verified against Studio Pro, an association-navigated param uses `AttributeRef` + `FormattingInfo` (DecimalPrecision/DateFormat/EnumFormat) for any type; the `toString()` wrapping is only the *direct*-attribute path's behavior. Do not "fix" the assoc path to wrap non-String — that diverges from Studio Pro. Tests: `mdl/backend/modelsdk/widget_template_assoc_test.go`, `mdl/executor/cmd_pages_describe_assoc_test.go`; bug-test `mdl-examples/bug-tests/dynamictext-contentparam-association.mdl` | +| Under the **default (modelsdk)** engine, any page widget with an **association datasource** (`datasource: $currentObject/Module.Assoc`, the master-detail idiom) fails at exec: `CreatePage: DataView source *pages.AssociationSource not yet supported by the modelsdk engine — rerun with MXCLI_ENGINE=legacy`. Same for a nested `listview` | `customWidgetDataSourceToGen` (pluggable widgets) already handled `*pages.AssociationSource`, but `dataViewSourceToGen`/`listViewSourceToGen` had no case → hit the "not yet supported" default. (Legacy doesn't error but writes a **broken** `Forms$AssociationSource{EntityRef: nil}` in `serializeListViewDataSource`, so legacy is not a valid reference — tracked as **L1** in [`docs/03-development/LEGACY_ENGINE_KNOWN_ISSUES.md`](../../docs/03-development/LEGACY_ENGINE_KNOWN_ISSUES.md); do **not** fix legacy, it will be removed when modelsdk matures) | `mdl/backend/modelsdk/widget_write.go` (`associationSourceToGen`, wired into `dataViewSourceToGen`+`listViewSourceToGen`) + `mdl/executor/cmd_pages_describe.go` (`associationSourcePath`) + `cmd_pages_describe_parse.go` (DataView/ListView datasource cases) + `cmd_pages_describe_output.go` (`associationDataSourceExpr`) | **modelsdk engine only.** Extract the proven `AssociationSource` block from `customWidgetDataSourceToGen` into a shared `associationSourceToGen` (Forms$AssociationSource → `IndirectEntityRef` of `EntityRefStep{Association, DestinationEntity}` + optional page-param `SourceVariable`; `$currentObject` → nil source var) and add a `case *pages.AssociationSource` to the DataView and ListView source builders. Matches the canonical legacy `serializeAssociationSource` (writer_widgets.go) and the Studio-Pro-verified `EntityRefStep` structure — NOT the broken `serializeListViewDataSource` stub. DESCRIBE reconstructs `$currentObject/Module.Assoc` from `EntityRef.Steps`. **An association source is valid only on LIST-producing widgets (listview/datagrid/gallery/templategrid), NOT a plain DataView** — Studio Pro rejects a DataView association source ("cannot have a data source of type association"); the widget still exec-creates (valid BSON), so `check` flags it with **MDL-WIDGET08** (`validateStaticWidget`). Tests: `mdl/backend/modelsdk/widget_assoc_source_test.go`, `mdl/executor/cmd_pages_describe_assoc_source_test.go`, `validate_widgets_test.go`; bug-tests `mdl-examples/bug-tests/assoc-datasource-modelsdk.mdl` (valid listview) + `dataview-association-source-rejected.fail.mdl` (MDL-WIDGET08) | +| `IF … ELSIF … ELSE … END IF` silently drops every ELSIF arm on write — DESCRIBE round-trips `if … else …` with the middle arms gone (read path unchanged by the fix ⇒ the arm was absent from the stored model — dropped on write); no error from `check`/`exec` (both engines) | `buildIfStatement` read only `exprs[0]`/`bodies[0]` and the trailing ELSE body; the grammar's `(ELSIF expression THEN microflowBody)*` pairs were never visited | `mdl/visitor/visitor_microflow_statements.go` (`buildIfStatement`) | Lower each ELSIF arm into a nested `IfStmt` in the ELSE branch of the arm before it (built innermost-first) — Mendix has no native elsif, nested exclusive splits are the canonical shape. Guarded by visitor tests (chain, no-else, plain-if regression) + `mdl-examples/bug-tests/745-elsif-arms-dropped.mdl`. Issue #745 | | Under the **default (modelsdk)** engine, any page widget with an **association datasource** (`datasource: $currentObject/Module.Assoc`, the master-detail idiom) fails at exec: `CreatePage: DataView source *pages.AssociationSource not yet supported by the modelsdk engine — rerun with MXCLI_ENGINE=legacy`. Same for a nested `listview` | `customWidgetDataSourceToGen` (pluggable widgets) already handled `*pages.AssociationSource`, but `dataViewSourceToGen`/`listViewSourceToGen` had no case → hit the "not yet supported" default. (Legacy doesn't error but writes a **broken** `Forms$AssociationSource{EntityRef: nil}` in `serializeListViewDataSource`, so legacy is not a valid reference — tracked as **L1** in [`docs/03-development/LEGACY_ENGINE_KNOWN_ISSUES.md`](../../docs/03-development/LEGACY_ENGINE_KNOWN_ISSUES.md); do **not** fix legacy, it will be removed when modelsdk matures) | `mdl/backend/modelsdk/widget_write.go` (`associationSourceToGen`, wired into `dataViewSourceToGen`+`listViewSourceToGen`) + `mdl/executor/cmd_pages_describe.go` (`associationSourcePath`) + `cmd_pages_describe_parse.go` (DataView/ListView datasource cases) + `cmd_pages_describe_output.go` (`associationDataSourceExpr`) | **modelsdk engine only.** Extract the proven `AssociationSource` block from `customWidgetDataSourceToGen` into a shared `associationSourceToGen` (Forms$AssociationSource → `IndirectEntityRef` of `EntityRefStep{Association, DestinationEntity}` + optional page-param `SourceVariable`; `$currentObject` → nil source var) and add a `case *pages.AssociationSource` to the DataView and ListView source builders. Matches the canonical legacy `serializeAssociationSource` (writer_widgets.go) and the Studio-Pro-verified `EntityRefStep` structure — NOT the broken `serializeListViewDataSource` stub. DESCRIBE reconstructs `$currentObject/Module.Assoc` from `EntityRef.Steps`. A `Forms$AssociationSource` is valid on **LIST-producing** widgets (listview/datagrid/gallery/templategrid). A plain **DataView** cannot use `Forms$AssociationSource` (MxBuild **CE6705**) — but the DataView *association* case is still valid via a **different** source type; see the "data from context over association" row below (an earlier fix wrongly rejected it with MDL-WIDGET08, now removed). Tests: `mdl/backend/modelsdk/widget_assoc_source_test.go`, `mdl/executor/cmd_pages_describe_assoc_source_test.go`; bug-test `mdl-examples/bug-tests/assoc-datasource-modelsdk.mdl` (valid listview) | | A **DataView bound to a to-one referenced object over an association** ("data from context", e.g. an inner DataView showing the referenced Employee's `Name`/`Email` inside an `Expense` DataView) can't be authored/round-tripped: `dataview dvEmp (datasource: $currentObject/Module.Assoc)` was rejected (MDL-WIDGET08), a bare nested `dataview { textbox (attribute: Name) }` bound children to the **parent** entity (CE1613), and `describe` dropped the source → describe/exec **silently strips** the association DataView (round-trip corruption) | A DataView over an association is **not** a `Forms$AssociationSource` (that's list-widgets-only; CE6705 on a DataView). It is a `Forms$DataViewSource` whose `EntityRef` is a `DomainModels$IndirectEntityRef` navigating the association (a DataViewSource *is* an `EntityPathSource`, so its EntityRef may be indirect — `generated/metamodel/types.go:558`). mxcli generated the wrong source type, then (Bug 5 v1) wrongly rejected the whole case | `mdl/backend/modelsdk/widget_write.go` (`dataViewContextAssociationSourceToGen`, wired into `dataViewSourceToGen`'s `*pages.AssociationSource` case) + `mdl/executor/cmd_pages_builder_v3_widgets.go` (removed the `buildDataViewV3` association guard) + `mdl/executor/validate_widgets.go` (removed MDL-WIDGET08) + `mdl/executor/cmd_pages_describe_parse.go` (`Forms$DataViewSource` reads `EntityRef.Steps` via `associationSourcePath`) | **modelsdk engine only.** For a DataView association datasource, emit `Forms$DataViewSource` with `EntityRef = IndirectEntityRef{EntityRefStep{Association, DestinationEntity}}` (+ optional page-param `SourceVariable`; `$currentObject` → none) — mirrors `associationSourceToGen` but wraps the IndirectEntityRef in a DataViewSource. Children then bind to the destination entity. DESCRIBE reconstructs `$currentObject/Module.Assoc`. Removes the earlier MDL-WIDGET08 over-rejection. **When two widget kinds share a navigation but MxBuild accepts it on only one, the distinction is the source `$Type` wrapper, not the EntityRef inside it** — check `generated/metamodel/types.go` for the widget's allowed `DataSource` subtypes. mxbuild-validated (`mxcli docker check --no-update-widgets` = 0 errors) + full describe→re-exec round-trip. Tests: `mdl/backend/modelsdk/widget_assoc_source_test.go` (`TestDataViewAssociationSource_Serialized`); bug-test `mdl-examples/bug-tests/dataview-context-association-source.mdl`. Bug 5 (reclassified) | | **DataGrid2** (`datagrid`) columns fail MxBuild **CE0463** "widget definition changed" on the **default (modelsdk)** engine — reported for custom-content columns, but actually **every** column (attribute too). Legacy is fine | The column WidgetObject's `Properties` were serialized in **alphabetical** order (`alignment, allowEventPropagation, attribute, …`) instead of the template's `PropertyTypes` order (`showContentAs, attribute, content, dynamicText, …`). Studio Pro hashes the object structure against the type and flags CE0463 on any order mismatch. Cause: the object-list item builder (`mdl/backend/widgetobj/builder.go:274`) orders by `NestedKeyOrder`, falling back to alphabetical when empty; the **modelsdk** registry loader never captured it (`types.PropertyTypeIDEntry` had no such field), while the MPR/legacy loader did → legacy correct, modelsdk broken. Top-level widget props were unaffected (ordered by a different path), which is why only the nested column object-list broke | `mdl/types/widget_property_type.go` (`PropertyTypeIDEntry.NestedKeyOrder`) + `modelsdk/widgets/loader.go` (thread `nestedKeyOrder` through `jsonValueToBSONWithNestedObjectType`→`extractNestedObjectType`→`extractNestedPropertyTypes`, append in template array order) + `mdl/backend/modelsdk/widget_pluggable_write.go` (`convertPropTypeIDs` copies `NestedKeyOrder`) | Mirror the `sdk/widgets` loader's existing `nestedKeyOrder` capture in the parallel `modelsdk/widgets` loader (dedup on first occurrence, append in PropertyTypes array order), add the field to `types.PropertyTypeIDEntry`, and copy it in the modelsdk `convertPropTypeIDs`. **Diagnose column-order CE0463 by mapping each column WidgetProperty's `TypePointer` → the type's `WidgetPropertyType.PropertyKey` and comparing the sequence to the template's PropertyTypes order.** Verified: modelsdk column order now equals the template (and legacy) order for both attribute and custom-content columns, and **`mxcli docker check --no-update-widgets` (raw output, no widget normalization) = 0 errors** for the report's exact pattern (attribute column + custom-content dynamictext-over-association + custom-content actionbutton with `show_page`). The custom-content child-widget subtree needed no separate fix — ordering was the whole cause. Tests: `modelsdk/widgets/nested_key_order_test.go`; bug-test `mdl-examples/bug-tests/datagrid2-custom-content-column.mdl`. **To validate CE-class MxBuild errors locally: `mxcli setup mxbuild -p app.mpr --force` then `mxcli docker check -p app.mpr --no-update-widgets`** (the `--no-update-widgets` is essential — the default runs `mx update-widgets` which auto-normalizes pluggable widgets and masks a real CE0463) | diff --git a/mdl-examples/bug-tests/745-elsif-arms-dropped.mdl b/mdl-examples/bug-tests/745-elsif-arms-dropped.mdl new file mode 100644 index 000000000..27f3594c0 --- /dev/null +++ b/mdl-examples/bug-tests/745-elsif-arms-dropped.mdl @@ -0,0 +1,50 @@ +-- ============================================================================ +-- Bug #745: IF/ELSIF/ELSE silently drops every ELSIF arm on write (both engines) +-- ============================================================================ +-- +-- Symptom (before fix): +-- A microflow authored with IF … ELSIF … ELSE … END IF lost every ELSIF +-- arm when written to the .mpr. Only the first IF condition/body and the +-- trailing ELSE body reached the model — no error, no warning. DESCRIBE +-- round-tripped `if … else …` with the elsif arm gone. The read path was +-- untouched by the fix, so the missing arm was absent from the stored +-- model itself (dropped on write). +-- +-- Root cause: +-- buildIfStatement (mdl/visitor/visitor_microflow_statements.go) read only +-- exprs[0]/bodies[0] and the trailing ELSE body; the grammar's +-- (ELSIF expression THEN microflowBody)* pairs were never visited. +-- +-- After fix: +-- Each ELSIF arm is lowered into a nested IfStmt in the ELSE branch of the +-- arm before it (Mendix has no native elsif; nested exclusive splits are +-- the canonical shape). DESCRIBE now round-trips the chain as nested +-- if/else, and every arm's content is present in the model. +-- +-- Usage: +-- mxcli exec mdl-examples/bug-tests/745-elsif-arms-dropped.mdl -p app.mpr +-- mxcli -p app.mpr -c "DESCRIBE MICROFLOW BugTestElsif.MF_Grade" +-- Expect: the 'two' and 'three' branches are present (nested if/else). +-- ============================================================================ + +create module BugTestElsif; + +create microflow BugTestElsif.MF_Grade ( + $In: integer +) +returns string as $Out +begin + declare $Out string = 'none'; + + if $In = 1 then + set $Out = 'one'; + elsif $In = 2 then + set $Out = 'two'; + elsif $In = 3 then + set $Out = 'three'; + else + set $Out = 'other'; + end if; + + return $Out; +end; diff --git a/mdl/visitor/visitor_microflow_elsif_test.go b/mdl/visitor/visitor_microflow_elsif_test.go new file mode 100644 index 000000000..605bfadbe --- /dev/null +++ b/mdl/visitor/visitor_microflow_elsif_test.go @@ -0,0 +1,165 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +// TestIfElsifArmsPreserved guards the "ELSIF arm silently dropped on write" +// bug: buildIfStatement only read the first condition/body pair and the +// trailing ELSE body, so every middle ELSIF arm of +// IF … THEN … (ELSIF … THEN …)* ELSE … END IF vanished from the AST — and +// therefore from the written .mpr — with no error. Mendix has no native +// elsif construct, so the visitor must lower each ELSIF arm into a nested +// IfStmt in the ELSE branch of the arm before it. +func TestIfElsifArmsPreserved(t *testing.T) { + prog, errs := Build(`create microflow M.TestElsif ($In: Integer) +returns String as $Out +begin + declare $Out String = 'none'; + if $In = 1 then + set $Out = 'one'; + elsif $In = 2 then + set $Out = 'two'; + elsif $In = 3 then + set $Out = 'three'; + else + set $Out = 'other'; + end if; + return $Out; +end;`) + if len(errs) > 0 { + t.Fatalf("parse errors: %v", errs) + } + mf := prog.Statements[0].(*ast.CreateMicroflowStmt) + + var outer *ast.IfStmt + for _, s := range mf.Body { + if ifs, ok := s.(*ast.IfStmt); ok { + outer = ifs + } + } + if outer == nil { + t.Fatal("no IfStmt found in microflow body") + } + + // Expect a 3-level chain: if $In=1 → elsif $In=2 → elsif $In=3 → else. + wantConds := []string{"$In = 1", "$In = 2", "$In = 3"} + cur := outer + for level, want := range wantConds { + if cur == nil { + t.Fatalf("level %d: expected a nested IfStmt for ELSIF arm %q, got none (arm dropped)", level, want) + } + if got := condSource(cur.Condition); got != "" && got != want { + t.Errorf("level %d: condition = %q, want %q", level, got, want) + } + if len(cur.ThenBody) != 1 { + t.Errorf("level %d: ThenBody has %d statements, want 1", level, len(cur.ThenBody)) + } + if !cur.HasElse { + t.Fatalf("level %d: HasElse = false, want true (chain must continue)", level) + } + if level == len(wantConds)-1 { + // Innermost arm carries the original ELSE body. + if len(cur.ElseBody) != 1 { + t.Fatalf("innermost ElseBody has %d statements, want 1 (the ELSE arm)", len(cur.ElseBody)) + } + if _, ok := cur.ElseBody[0].(*ast.IfStmt); ok { + t.Fatal("innermost ElseBody is another IfStmt; want the plain ELSE statement") + } + return + } + if len(cur.ElseBody) != 1 { + t.Fatalf("level %d: ElseBody has %d statements, want exactly the nested IfStmt", level, len(cur.ElseBody)) + } + next, ok := cur.ElseBody[0].(*ast.IfStmt) + if !ok { + t.Fatalf("level %d: ElseBody[0] is %T, want *ast.IfStmt (lowered ELSIF arm)", level, cur.ElseBody[0]) + } + cur = next + } +} + +// TestIfElsifWithoutElse — the chain must also work with no trailing ELSE: +// the innermost lowered arm then has no ELSE branch at all. +func TestIfElsifWithoutElse(t *testing.T) { + prog, errs := Build(`create microflow M.TestElsifNoElse ($In: Integer) +begin + if $In = 1 then + log info 'one'; + elsif $In = 2 then + log info 'two'; + end if; +end;`) + if len(errs) > 0 { + t.Fatalf("parse errors: %v", errs) + } + mf := prog.Statements[0].(*ast.CreateMicroflowStmt) + var outer *ast.IfStmt + for _, s := range mf.Body { + if ifs, ok := s.(*ast.IfStmt); ok { + outer = ifs + } + } + if outer == nil { + t.Fatal("no IfStmt found in microflow body") + } + if !outer.HasElse || len(outer.ElseBody) != 1 { + t.Fatalf("outer arm: HasElse=%v ElseBody len=%d, want the lowered ELSIF in ELSE", outer.HasElse, len(outer.ElseBody)) + } + inner, ok := outer.ElseBody[0].(*ast.IfStmt) + if !ok { + t.Fatalf("outer ElseBody[0] is %T, want *ast.IfStmt (the ELSIF arm)", outer.ElseBody[0]) + } + if len(inner.ThenBody) != 1 { + t.Errorf("inner ThenBody has %d statements, want 1", len(inner.ThenBody)) + } + if inner.HasElse || len(inner.ElseBody) != 0 { + t.Errorf("inner arm: HasElse=%v ElseBody len=%d, want no ELSE (source had none)", inner.HasElse, len(inner.ElseBody)) + } +} + +// TestPlainIfElseUnchanged — regression guard: the common IF/ELSE (no ELSIF) +// shape must build exactly as before, with no gratuitous nesting. +func TestPlainIfElseUnchanged(t *testing.T) { + prog, errs := Build(`create microflow M.TestPlain ($In: Integer) +begin + if $In = 1 then + log info 'one'; + else + log info 'other'; + end if; +end;`) + if len(errs) > 0 { + t.Fatalf("parse errors: %v", errs) + } + mf := prog.Statements[0].(*ast.CreateMicroflowStmt) + var outer *ast.IfStmt + for _, s := range mf.Body { + if ifs, ok := s.(*ast.IfStmt); ok { + outer = ifs + } + } + if outer == nil { + t.Fatal("no IfStmt found in microflow body") + } + if len(outer.ThenBody) != 1 || !outer.HasElse || len(outer.ElseBody) != 1 { + t.Fatalf("plain if/else shape changed: Then=%d HasElse=%v Else=%d", len(outer.ThenBody), outer.HasElse, len(outer.ElseBody)) + } + if _, nested := outer.ElseBody[0].(*ast.IfStmt); nested { + t.Fatal("plain ELSE body was wrapped in a nested IfStmt") + } +} + +// condSource extracts the preserved source text of a condition when the +// visitor kept it (SourceExpr); returns "" when it did not, in which case +// the caller skips the textual comparison (structure is asserted regardless). +func condSource(e ast.Expression) string { + if se, ok := e.(*ast.SourceExpr); ok { + return se.Source + } + return "" +} diff --git a/mdl/visitor/visitor_microflow_statements.go b/mdl/visitor/visitor_microflow_statements.go index c76ca5442..76de69513 100644 --- a/mdl/visitor/visitor_microflow_statements.go +++ b/mdl/visitor/visitor_microflow_statements.go @@ -1288,25 +1288,47 @@ func buildIfStatement(ctx parser.IIfStatementContext) *ast.IfStmt { } ifCtx := ctx.(*parser.IfStatementContext) - stmt := &ast.IfStmt{} - - // Get all expressions (condition for IF and ELSIFs) + // Grammar: IF expression THEN microflowBody + // (ELSIF expression THEN microflowBody)* + // (ELSE microflowBody)? END IF + // exprs[i] pairs with bodies[i]; one trailing extra body is the ELSE branch. exprs := ifCtx.AllExpression() - if len(exprs) > 0 { - stmt.Condition = buildSourceExpression(exprs[0]) - } - - // Get all microflow bodies (THEN, ELSIF THENs, ELSE) bodies := ifCtx.AllMicroflowBody() - if len(bodies) > 0 { - stmt.ThenBody = buildMicroflowBody(bodies[0]) + + if len(exprs) == 0 { + // Defensive only — the grammar guarantees at least one condition. + stmt := &ast.IfStmt{} + if len(bodies) > 0 { + stmt.ThenBody = buildMicroflowBody(bodies[0]) + } + return stmt } - // Last body is ELSE if there's no ELSIF or if there are more bodies than expressions + + hasElse := len(bodies) > len(exprs) || ifCtx.ELSE() != nil + var elseBody []ast.MicroflowStatement if len(bodies) > len(exprs) { - stmt.HasElse = true - stmt.ElseBody = buildMicroflowBody(bodies[len(bodies)-1]) - } else if ifCtx.ELSE() != nil { - stmt.HasElse = true + elseBody = buildMicroflowBody(bodies[len(bodies)-1]) + } + + // Mendix has no native elsif construct, so lower each ELSIF arm into a + // nested IfStmt in the ELSE branch of the arm before it (built innermost + // first). Previously only exprs[0]/bodies[0] and the trailing ELSE were + // read, silently dropping every ELSIF arm from the written model. + var stmt *ast.IfStmt + for i := len(exprs) - 1; i >= 0; i-- { + s := &ast.IfStmt{} + s.Condition = buildSourceExpression(exprs[i]) + if i < len(bodies) { + s.ThenBody = buildMicroflowBody(bodies[i]) + } + if stmt != nil { + s.HasElse = true + s.ElseBody = []ast.MicroflowStatement{stmt} + } else { + s.HasElse = hasElse + s.ElseBody = elseBody + } + stmt = s } return stmt