Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- **Path normalization** — Relative paths in `MetadataUrl` are automatically converted to absolute `file://` URLs for Studio Pro compatibility
- **ServiceUrl validation** — `ServiceUrl` parameter must now be a constant reference (e.g., `@Module.ConstantName`) to enforce Mendix best practice
- **Shared URL utilities** — `internal/pathutil` package with `NormalizeURL()`, `URIToPath()`, and `PathFromURL()` for reuse across components

### Changed

- **MDL string literal escapes** — `mdlQuote`/`unquoteString` now treat `\n`, `\r`, `\t`, and `\\` inside single-quoted literals as escape sequences (previously a literal backslash followed by the letter). This is a compatibility break for any MDL script that intentionally embedded a raw `\n` / `\t` / `\\` as two characters; such scripts must now double the backslash (`\\n` to preserve the two-character form). Applies to `LOG` messages, `@caption`/`@annotation` text, and other string literals round-tripped via the describer.

## [0.7.0] - 2026-04-21

### Added
Expand Down
46 changes: 46 additions & 0 deletions mdl-examples/bug-tests/264-log-node-expression-roundtrip.mdl
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
-- ============================================================================
-- Bug #264: Log node expressions and microflow describe roundtrip
-- ============================================================================
--
-- Three related regressions surfaced together while exercising real .mpr files:
--
-- 1. Legacy `NewCaseValue` on sequence flows (Mx 9) was being dropped by the
-- parser, so re-describing a Mx 9 project silently lost decision labels.
-- 2. The describer bailed or emitted malformed MDL when it hit partially
-- understood activity shapes — a single unknown subfield broke the entire
-- microflow roundtrip.
-- 3. The MDL grammar didn't accept the `log` activity's expression-typed NODE
-- parameter, so any microflow containing a log node with a variable or
-- constant reference failed to re-parse after describe.
--
-- Usage:
-- mxcli exec mdl-examples/bug-tests/264-log-node-expression-roundtrip.mdl -p app.mpr
-- Then: mxcli describe microflow BugTest264.MF_LogWithNode -p app.mpr
-- The output must re-execute cleanly against the same project.
-- ============================================================================

create module BugTest264;

-- String-literal node (backwards-compatible shape)
create microflow BugTest264.MF_LogWithLiteralNode ()
begin
log info node 'BugTest264' 'started';
end;
/

-- Variable-ref node — exercises Node expression path
create microflow BugTest264.MF_LogWithNode (
$nodeName: string
)
begin
log info node $nodeName 'hello';
end;
/

-- Multi-line message with embedded newline — exercises the mdlQuote escape
-- round-trip (`\n` is decoded on parse, re-encoded on describe).
create microflow BugTest264.MF_LogMultilineMessage ()
begin
log info node 'BugTest264' 'line 1\nline 2\nline 3';
end;
/
4 changes: 2 additions & 2 deletions mdl/ast/ast_microflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -261,10 +261,10 @@ func (p *TemplateParam) IsDataSourceRef() bool {
return p.DataSourceName != ""
}

// LogStmt represents: LOG LEVEL NODE 'node' 'message' [WITH params]
// LogStmt represents: LOG LEVEL [NODE expr] message [WITH params]
type LogStmt struct {
Level LogLevel // Log level (INFO, WARNING, etc.)
Node string // Log node name
Node Expression // Optional log node expression
Message Expression // Message expression
Template []TemplateParam // Optional WITH template params
Annotations *ActivityAnnotations // Optional @position, @caption, @color, @annotation
Expand Down
151 changes: 151 additions & 0 deletions mdl/executor/bugfix_regression_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,157 @@ func TestFormatActivity_WhileLoop(t *testing.T) {
}
}

func TestAddLoopStatement_PreservesAnnotatedPosition(t *testing.T) {
fb := &flowBuilder{
posX: 350,
posY: 200,
spacing: HorizontalSpacing,
varTypes: map[string]string{"Items": "List of Test.Item"},
declaredVars: map[string]string{},
measurer: &layoutMeasurer{varTypes: map[string]string{"Items": "List of Test.Item"}},
}

stmt := &ast.LoopStmt{
LoopVariable: "Item",
ListVariable: "Items",
Annotations: &ast.ActivityAnnotations{
Position: &ast.Position{X: 350, Y: 200},
},
}

id := fb.addLoopStatement(stmt)
if id == "" {
t.Fatal("expected loop activity ID")
}

loop, ok := fb.objects[len(fb.objects)-1].(*microflows.LoopedActivity)
if !ok {
t.Fatalf("expected LoopedActivity, got %T", fb.objects[len(fb.objects)-1])
}
if loop.Position.X != 350 || loop.Position.Y != 200 {
t.Fatalf("got loop position (%d, %d), want (350, 200)", loop.Position.X, loop.Position.Y)
}
wantNextX := 350 + loop.Size.Width/2 + HorizontalSpacing
if fb.posX != wantNextX {
t.Fatalf("got next posX %d, want %d", fb.posX, wantNextX)
}
}

func TestAddWhileStatement_PreservesAnnotatedPosition(t *testing.T) {
fb := &flowBuilder{
posX: 420,
posY: 180,
spacing: HorizontalSpacing,
varTypes: map[string]string{},
declaredVars: map[string]string{},
measurer: &layoutMeasurer{varTypes: map[string]string{}},
}

stmt := &ast.WhileStmt{
Condition: &ast.BinaryExpr{
Left: &ast.VariableExpr{Name: "Count"},
Operator: "<",
Right: &ast.LiteralExpr{Kind: ast.LiteralInteger, Value: int64(10)},
},
Annotations: &ast.ActivityAnnotations{
Position: &ast.Position{X: 420, Y: 180},
},
}

id := fb.addWhileStatement(stmt)
if id == "" {
t.Fatal("expected while activity ID")
}

loop, ok := fb.objects[len(fb.objects)-1].(*microflows.LoopedActivity)
if !ok {
t.Fatalf("expected LoopedActivity, got %T", fb.objects[len(fb.objects)-1])
}
if loop.Position.X != 420 || loop.Position.Y != 180 {
t.Fatalf("got while position (%d, %d), want (420, 180)", loop.Position.X, loop.Position.Y)
}
wantNextX := 420 + loop.Size.Width/2 + HorizontalSpacing
if fb.posX != wantNextX {
t.Fatalf("got next posX %d, want %d", fb.posX, wantNextX)
}
}

func TestAddLogMessageAction_PreservesNodeExpression(t *testing.T) {
fb := &flowBuilder{
posX: 100,
posY: 200,
spacing: HorizontalSpacing,
measurer: &layoutMeasurer{},
}

stmt := &ast.LogStmt{
Level: ast.LogInfo,
Node: &ast.ConstantRefExpr{
QualifiedName: ast.QualifiedName{Module: "TestModule", Name: "SecurityLogNode"},
},
Message: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "User added"},
}

id := fb.addLogMessageAction(stmt)
if id == "" {
t.Fatal("expected log activity ID")
}

activity, ok := fb.objects[len(fb.objects)-1].(*microflows.ActionActivity)
if !ok {
t.Fatalf("expected ActionActivity, got %T", fb.objects[len(fb.objects)-1])
}

action, ok := activity.Action.(*microflows.LogMessageAction)
if !ok {
t.Fatalf("expected LogMessageAction, got %T", activity.Action)
}

if action.LogNodeName != "@TestModule.SecurityLogNode" {
t.Fatalf("got log node %q, want %q", action.LogNodeName, "@TestModule.SecurityLogNode")
}
}

func TestAddLogMessageAction_TemplateLiteralDoesNotKeepQuotes(t *testing.T) {
fb := &flowBuilder{
posX: 100,
posY: 200,
spacing: HorizontalSpacing,
measurer: &layoutMeasurer{},
}

stmt := &ast.LogStmt{
Level: ast.LogInfo,
Node: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "App"},
Message: &ast.LiteralExpr{
Kind: ast.LiteralString,
Value: "Order {1}",
},
Template: []ast.TemplateParam{
{Index: 1, Value: &ast.VariableExpr{Name: "OrderNumber"}},
},
}

id := fb.addLogMessageAction(stmt)
if id == "" {
t.Fatal("expected log activity ID")
}

activity, ok := fb.objects[len(fb.objects)-1].(*microflows.ActionActivity)
if !ok {
t.Fatalf("expected ActionActivity, got %T", fb.objects[len(fb.objects)-1])
}

action, ok := activity.Action.(*microflows.LogMessageAction)
if !ok {
t.Fatalf("expected LogMessageAction, got %T", activity.Action)
}

if got := action.MessageTemplate.Translations["en_US"]; got != "Order {1}" {
t.Fatalf("got message template %q, want %q", got, "Order {1}")
}
}

// =============================================================================
// Issue #19: Long type must not be downgraded to Integer
// =============================================================================
Expand Down
6 changes: 3 additions & 3 deletions mdl/executor/cmd_diff_mdl.go
Original file line number Diff line number Diff line change
Expand Up @@ -345,9 +345,9 @@ func microflowStatementToMDL(ctx *ExecContext, stmt ast.MicroflowStatement, inde
lines = append(lines, indentStr+"end loop;")

case *ast.LogStmt:
nodeStr := s.Node
if !strings.HasPrefix(nodeStr, "'") {
nodeStr = "'" + nodeStr + "'"
nodeStr := defaultLogNodeExpression
if s.Node != nil {
nodeStr = diffExpressionToString(ctx, s.Node)
}
msgStr := diffExpressionToString(ctx, s.Message)
stmt := fmt.Sprintf("%slog %s node %s %s", indentStr, strings.ToLower(s.Level.String()), nodeStr, msgStr)
Expand Down
18 changes: 16 additions & 2 deletions mdl/executor/cmd_microflows_builder_calls.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ import (
"github.com/mendixlabs/mxcli/sdk/microflows"
)

// defaultLogNodeExpression is the quoted Mendix expression used for the log
// node when none is specified on a LOG statement. Single source of truth shared
// by the builder, the formatter, and cmd_diff_mdl.
const defaultLogNodeExpression = "'Application'"

// addLogMessageAction creates a LOG statement as a LogMessageAction.
func (fb *flowBuilder) addLogMessageAction(s *ast.LogStmt) model.ID {
logLevel := microflows.LogLevelInfo
Expand All @@ -38,7 +43,11 @@ func (fb *flowBuilder) addLogMessageAction(s *ast.LogStmt) model.ID {

if len(s.Template) > 0 {
// Use provided template parameters
templateText = fb.exprToString(s.Message)
if lit, ok := s.Message.(*ast.LiteralExpr); ok && lit.Kind == ast.LiteralString {
templateText = fmt.Sprintf("%v", lit.Value)
} else {
templateText = fb.exprToString(s.Message)
}
// Sort parameters by index to ensure correct order
maxIndex := 0
for _, p := range s.Template {
Expand All @@ -61,10 +70,15 @@ func (fb *flowBuilder) addLogMessageAction(s *ast.LogStmt) model.ID {
templateParams = []string{fb.exprToString(s.Message)}
}

logNodeName := defaultLogNodeExpression
if s.Node != nil {
logNodeName = fb.exprToString(s.Node)
}

action := &microflows.LogMessageAction{
BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())},
LogLevel: logLevel,
LogNodeName: "'" + s.Node + "'", // Store as expression (e.g., 'TEST')
LogNodeName: logNodeName,
MessageTemplate: &model.Text{
BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())},
Translations: map[string]string{
Expand Down
22 changes: 18 additions & 4 deletions mdl/executor/cmd_microflows_builder_control.go
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,13 @@ func (fb *flowBuilder) addLoopStatement(s *ast.LoopStmt) model.ID {
innerStartX := LoopPadding + iteratorSpace // Extra offset for iterator icon and label
innerStartY := LoopPadding + ActivityHeight/2 // Center activities vertically with some padding

loopLeftX := fb.posX
loopCenterX := loopLeftX + loopWidth/2
if s.Annotations != nil && s.Annotations.Position != nil {
loopCenterX = s.Annotations.Position.X
loopLeftX = loopCenterX - loopWidth/2
}

// Add loop variable to varTypes with element type derived from list type
// If $ProductList is "List of MfTest.Product", then $Product is "MfTest.Product"
if fb.varTypes != nil {
Expand Down Expand Up @@ -335,7 +342,7 @@ func (fb *flowBuilder) addLoopStatement(s *ast.LoopStmt) model.ID {
loop := &microflows.LoopedActivity{
BaseMicroflowObject: microflows.BaseMicroflowObject{
BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())},
Position: model.Point{X: fb.posX + loopWidth/2, Y: fb.posY},
Position: model.Point{X: loopCenterX, Y: fb.posY},
Size: model.Size{Width: loopWidth, Height: loopHeight},
},
LoopSource: &microflows.IterableList{
Expand All @@ -362,7 +369,7 @@ func (fb *flowBuilder) addLoopStatement(s *ast.LoopStmt) model.ID {
fb.applyAnnotations(loop.ID, savedLoopAnnotations)
}

fb.posX += loopWidth + HorizontalSpacing
fb.posX = loopLeftX + loopWidth + HorizontalSpacing

return loop.ID
}
Expand All @@ -383,6 +390,13 @@ func (fb *flowBuilder) addWhileStatement(s *ast.WhileStmt) model.ID {
innerStartX := LoopPadding
innerStartY := LoopPadding + ActivityHeight/2

loopLeftX := fb.posX
loopCenterX := loopLeftX + loopWidth/2
if s.Annotations != nil && s.Annotations.Position != nil {
loopCenterX = s.Annotations.Position.X
loopLeftX = loopCenterX - loopWidth/2
}

loopBuilder := &flowBuilder{
posX: innerStartX,
posY: innerStartY,
Expand Down Expand Up @@ -417,7 +431,7 @@ func (fb *flowBuilder) addWhileStatement(s *ast.WhileStmt) model.ID {
loop := &microflows.LoopedActivity{
BaseMicroflowObject: microflows.BaseMicroflowObject{
BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())},
Position: model.Point{X: fb.posX + loopWidth/2, Y: fb.posY},
Position: model.Point{X: loopCenterX, Y: fb.posY},
Size: model.Size{Width: loopWidth, Height: loopHeight},
},
LoopSource: &microflows.WhileLoopCondition{
Expand All @@ -439,7 +453,7 @@ func (fb *flowBuilder) addWhileStatement(s *ast.WhileStmt) model.ID {
fb.applyAnnotations(loop.ID, savedWhileAnnotations)
}

fb.posX += loopWidth + HorizontalSpacing
fb.posX = loopLeftX + loopWidth + HorizontalSpacing

return loop.ID
}
Loading