diff --git a/mdl-examples/bug-tests/322-multiline-source-expression-whitespace.mdl b/mdl-examples/bug-tests/322-multiline-source-expression-whitespace.mdl new file mode 100644 index 00000000..25ef890d --- /dev/null +++ b/mdl-examples/bug-tests/322-multiline-source-expression-whitespace.mdl @@ -0,0 +1,61 @@ +-- ============================================================================ +-- Bug #322: Multiline source expression whitespace lost on roundtrip +-- ============================================================================ +-- +-- Symptom (before fix): +-- The visitor rebuilt expression strings from parsed expression nodes, +-- so original line breaks and whitespace inside DECLARE initial values +-- and LOG template parameters were normalized away. A multiline +-- `declare $X string = ...` statement that authors had carefully +-- formatted across several lines came back as a single very long line +-- after describe → exec → describe. Inter-parameter blank lines in +-- `LOG ... WITH (...)` were similarly collapsed, making real-world +-- microflows non-fixpoint and producing noisy `mxcli diff-local` output. +-- +-- After fix: +-- Added a `SourceExpr` AST node that wraps an expression with its +-- original source text. The visitor uses `buildSourceExpression` for +-- source-sensitive declare/log/while expressions, and the executor +-- serializes `SourceExpr` through to MDL output so the original +-- whitespace and line breaks survive every roundtrip. +-- +-- Usage: +-- mxcli exec mdl-examples/bug-tests/322-multiline-source-expression-whitespace.mdl -p app.mpr +-- mxcli -p app.mpr -c "describe microflow BugTest322.MF_MultilineDeclare" +-- mxcli -p app.mpr -c "describe microflow BugTest322.MF_MultilineLogTemplate" +-- The describe outputs must keep the multiline shape and must be +-- fixpoints under describe → exec → describe. +-- ============================================================================ + +create module BugTest322; + +-- DECLARE initial value spread across several lines with leading `+`. The +-- describer must preserve the line breaks rather than collapse them onto +-- one line. +create microflow BugTest322.MF_MultilineDeclare ( + $Page: integer, + $Token: string +) +returns string as $Endpoint +begin + declare $Endpoint string = '/api/v1' ++ '/items?page=' + toString($Page) ++ '&token=' + $Token; + + return $Endpoint; +end; +/ + +-- LOG template parameters separated by a blank line. The newline-only +-- whitespace between `{1} = toString($Count)` and the next parameter must +-- survive the roundtrip. +create microflow BugTest322.MF_MultilineLogTemplate ( + $Count: integer, + $Endpoint: string +) +begin + log info node 'BugTest322' 'Processed {1} items for {2}' with ({1} = toString($Count) + +, {2} = $Endpoint); +end; +/ diff --git a/mdl/ast/ast_expression.go b/mdl/ast/ast_expression.go index 72115cea..7a3acb24 100644 --- a/mdl/ast/ast_expression.go +++ b/mdl/ast/ast_expression.go @@ -125,6 +125,15 @@ type IfThenElseExpr struct { func (e *IfThenElseExpr) isExpression() {} +// SourceExpr preserves original expression source text while keeping the parsed +// expression tree available for callers that need semantic inspection. +type SourceExpr struct { + Expression Expression + Source string +} + +func (e *SourceExpr) isExpression() {} + // ============================================================================ // XPath-Specific Expression Types // ============================================================================ diff --git a/mdl/executor/cmd_microflows_builder.go b/mdl/executor/cmd_microflows_builder.go index f4d5f4fd..85c86e39 100644 --- a/mdl/executor/cmd_microflows_builder.go +++ b/mdl/executor/cmd_microflows_builder.go @@ -266,6 +266,14 @@ func (fb *flowBuilder) resolveAssociationPaths(expr ast.Expression) ast.Expressi ThenExpr: fb.resolveAssociationPaths(e.ThenExpr), ElseExpr: fb.resolveAssociationPaths(e.ElseExpr), } + case *ast.SourceExpr: + if e.Source != "" { + return e + } + return &ast.SourceExpr{ + Expression: fb.resolveAssociationPaths(e.Expression), + Source: e.Source, + } default: return expr } @@ -380,6 +388,8 @@ func unwrapParenCall(expr ast.Expression) *ast.FunctionCallExpr { return e case *ast.ParenExpr: expr = e.Inner + case *ast.SourceExpr: + expr = e.Expression default: return nil } diff --git a/mdl/executor/cmd_microflows_helpers.go b/mdl/executor/cmd_microflows_helpers.go index 255fcd96..4fda9055 100644 --- a/mdl/executor/cmd_microflows_helpers.go +++ b/mdl/executor/cmd_microflows_helpers.go @@ -321,6 +321,11 @@ func expressionToString(expr ast.Expression) string { thenStr := expressionToString(e.ThenExpr) elseStr := expressionToString(e.ElseExpr) return "if " + cond + " then " + thenStr + " else " + elseStr + case *ast.SourceExpr: + if e.Source != "" { + return e.Source + } + return expressionToString(e.Expression) default: return "" } @@ -373,6 +378,11 @@ func expressionToXPath(expr ast.Expression) string { return expressionToString(expr) case *ast.QualifiedNameExpr: return qualifiedNameToXPath(e) + case *ast.SourceExpr: + if e.Source != "" { + return e.Source + } + return expressionToXPath(e.Expression) default: // For all other expression types, the standard serialization is correct return expressionToString(expr) diff --git a/mdl/visitor/visitor_microflow_actions.go b/mdl/visitor/visitor_microflow_actions.go index d8576353..703f1adc 100644 --- a/mdl/visitor/visitor_microflow_actions.go +++ b/mdl/visitor/visitor_microflow_actions.go @@ -51,10 +51,10 @@ func buildLogStatement(ctx parser.ILogStatementContext) *ast.LogStmt { // LOG always has a message expression; when NODE is present the first // expression is the node and the second is the message. if logCtx.NODE() != nil && len(exprs) > 1 { - stmt.Node = buildExpression(exprs[0]) - stmt.Message = buildExpression(exprs[1]) + stmt.Node = buildSourceExpression(exprs[0]) + stmt.Message = buildSourceExpression(exprs[1]) } else if len(exprs) > 0 { - stmt.Message = buildExpression(exprs[0]) + stmt.Message = buildSourceExpression(exprs[0]) } // Parse template parameters: WITH ({1} = expr, {2} = expr, ...) @@ -79,7 +79,8 @@ func buildTemplateParams(ctx parser.ITemplateParamsContext) []ast.TemplateParam var result []ast.TemplateParam // Handle WITH ({1} = expr, {2} = expr, ...) syntax - for _, param := range paramsCtx.AllTemplateParam() { + allParams := paramsCtx.AllTemplateParam() + for i, param := range allParams { paramCtx := param.(*parser.TemplateParamContext) indexStr := paramCtx.NUMBER_LITERAL().GetText() index, _ := strconv.Atoi(indexStr) @@ -89,7 +90,8 @@ func buildTemplateParams(ctx parser.ITemplateParamsContext) []ast.TemplateParam // Parse the expression and check for data source attribute reference if exprCtx := paramCtx.Expression(); exprCtx != nil { - expr := buildExpression(exprCtx) + expr := buildSourceExpression(exprCtx) + expr = appendTemplateParamTrailingWhitespace(paramsCtx, allParams, i, exprCtx, expr) tp.Value = expr // Check if this is a $Widget.Attr pattern (AttributePathExpr with Path) @@ -117,6 +119,137 @@ func buildTemplateParams(ctx parser.ITemplateParamsContext) []ast.TemplateParam return result } +func appendTemplateParamTrailingWhitespace( + paramsCtx *parser.TemplateParamsContext, + allParams []parser.ITemplateParamContext, + index int, + exprCtx parser.IExpressionContext, + expr ast.Expression, +) ast.Expression { + trailing := templateParamTrailingWhitespace(paramsCtx, allParams, index, exprCtx) + if trailing == "" || !strings.ContainsAny(trailing, "\r\n") { + return expr + } + + source := strings.TrimSpace(extractOriginalText(exprCtx.(antlr.ParserRuleContext))) + innerExpr := expr + if sourceExpr, ok := expr.(*ast.SourceExpr); ok { + source = sourceExpr.Source + innerExpr = sourceExpr.Expression + } + return &ast.SourceExpr{Expression: innerExpr, Source: source + trailing} +} + +func templateParamTrailingWhitespace( + paramsCtx *parser.TemplateParamsContext, + allParams []parser.ITemplateParamContext, + index int, + exprCtx parser.IExpressionContext, +) string { + exprRule, ok := exprCtx.(antlr.ParserRuleContext) + if !ok || exprRule.GetStop() == nil { + return "" + } + input := exprRule.GetStop().GetInputStream() + if input == nil { + return "" + } + + start := exprRule.GetStop().GetStop() + 1 + end := -1 + delimiter := byte(')') + if index+1 < len(allParams) { + nextParam := allParams[index+1].(antlr.ParserRuleContext) + end = nextParam.GetStart().GetStart() - 1 + delimiter = ',' + } else if paramsCtx.GetStop() != nil { + end = paramsCtx.GetStop().GetStart() - 1 + } + if start < 0 || end < start { + return "" + } + + gap := input.GetText(start, end) + if delimiter == ',' { + comma := strings.IndexByte(gap, ',') + if comma == -1 { + return "" + } + gap = gap[:comma] + } + if strings.TrimSpace(gap) != "" { + return "" + } + return gap +} + +func nextParserRuleContext[T any](items []T, index int) antlr.ParserRuleContext { + if index+1 >= len(items) { + return nil + } + next, _ := any(items[index+1]).(antlr.ParserRuleContext) + return next +} + +func appendExpressionListTrailingWhitespace( + parent antlr.ParserRuleContext, + next antlr.ParserRuleContext, + exprCtx parser.IExpressionContext, + expr ast.Expression, +) ast.Expression { + trailing := expressionListTrailingWhitespace(parent, next, exprCtx) + if trailing == "" || !strings.ContainsAny(trailing, "\r\n") { + return expr + } + + source := strings.TrimSpace(extractOriginalText(exprCtx.(antlr.ParserRuleContext))) + innerExpr := expr + if sourceExpr, ok := expr.(*ast.SourceExpr); ok { + source = sourceExpr.Source + innerExpr = sourceExpr.Expression + } + return &ast.SourceExpr{Expression: innerExpr, Source: source + trailing} +} + +func expressionListTrailingWhitespace( + parent antlr.ParserRuleContext, + next antlr.ParserRuleContext, + exprCtx parser.IExpressionContext, +) string { + exprRule, ok := exprCtx.(antlr.ParserRuleContext) + if !ok || exprRule.GetStop() == nil { + return "" + } + input := exprRule.GetStop().GetInputStream() + if input == nil { + return "" + } + + start := exprRule.GetStop().GetStop() + 1 + end := -1 + if next != nil { + end = next.GetStart().GetStart() - 1 + } else if parent != nil && parent.GetStop() != nil { + end = parent.GetStop().GetStart() - 1 + } + if start < 0 || end < start { + return "" + } + + gap := input.GetText(start, end) + if next != nil { + comma := strings.IndexByte(gap, ',') + if comma == -1 { + return "" + } + gap = gap[:comma] + } + if strings.TrimSpace(gap) != "" { + return "" + } + return gap +} + // buildCallMicroflowStatement converts CALL MICROFLOW statement context to CallMicroflowStmt. // Grammar: (VARIABLE EQUALS)? CALL MICROFLOW qualifiedName LPAREN callArgumentList? RPAREN func buildCallMicroflowStatement(ctx parser.ICallMicroflowStatementContext) *ast.CallMicroflowStmt { @@ -291,7 +424,8 @@ func buildCallArgumentList(ctx parser.ICallArgumentListContext) []ast.CallArgume listCtx := ctx.(*parser.CallArgumentListContext) var args []ast.CallArgument - for _, argCtx := range listCtx.AllCallArgument() { + allArgs := listCtx.AllCallArgument() + for i, argCtx := range allArgs { arg := argCtx.(*parser.CallArgumentContext) ca := ast.CallArgument{} @@ -302,7 +436,8 @@ func buildCallArgumentList(ctx parser.ICallArgumentListContext) []ast.CallArgume ca.Name = parameterNameText(pn) } if expr := arg.Expression(); expr != nil { - ca.Value = buildExpression(expr) + value := buildSourceExpression(expr) + ca.Value = appendExpressionListTrailingWhitespace(listCtx, nextParserRuleContext(allArgs, i), expr, value) } args = append(args, ca) @@ -319,7 +454,8 @@ func buildMemberAssignmentList(ctx parser.IMemberAssignmentListContext) []ast.Ch listCtx := ctx.(*parser.MemberAssignmentListContext) var items []ast.ChangeItem - for _, assignCtx := range listCtx.AllMemberAssignment() { + allAssignments := listCtx.AllMemberAssignment() + for i, assignCtx := range allAssignments { assign := assignCtx.(*parser.MemberAssignmentContext) ci := ast.ChangeItem{} @@ -328,7 +464,8 @@ func buildMemberAssignmentList(ctx parser.IMemberAssignmentListContext) []ast.Ch ci.Attribute = memberAttributeNameText(name) } if expr := assign.Expression(); expr != nil { - ci.Value = buildExpression(expr) + value := buildSourceExpression(expr) + ci.Value = appendExpressionListTrailingWhitespace(listCtx, nextParserRuleContext(allAssignments, i), expr, value) } items = append(items, ci) @@ -345,7 +482,8 @@ func buildChangeList(ctx parser.IChangeListContext) []ast.ChangeItem { listCtx := ctx.(*parser.ChangeListContext) var items []ast.ChangeItem - for _, itemCtx := range listCtx.AllChangeItem() { + allItems := listCtx.AllChangeItem() + for i, itemCtx := range allItems { item := itemCtx.(*parser.ChangeItemContext) ci := ast.ChangeItem{} @@ -353,7 +491,8 @@ func buildChangeList(ctx parser.IChangeListContext) []ast.ChangeItem { ci.Attribute = id.GetText() } if expr := item.Expression(); expr != nil { - ci.Value = buildExpression(expr) + value := buildSourceExpression(expr) + ci.Value = appendExpressionListTrailingWhitespace(listCtx, nextParserRuleContext(allItems, i), expr, value) } items = append(items, ci) @@ -405,7 +544,7 @@ func buildListOperationStatement(ctx parser.IListOperationStatementContext) *ast stmt.InputVariable = strings.TrimPrefix(vars[0].GetText(), "$") } if expr := op.Expression(0); expr != nil { - stmt.Condition = buildExpression(expr) + stmt.Condition = buildSourceExpression(expr) } } else if op.FILTER() != nil { stmt.Operation = ast.ListOpFilter @@ -413,7 +552,7 @@ func buildListOperationStatement(ctx parser.IListOperationStatementContext) *ast stmt.InputVariable = strings.TrimPrefix(vars[0].GetText(), "$") } if expr := op.Expression(0); expr != nil { - stmt.Condition = buildExpression(expr) + stmt.Condition = buildSourceExpression(expr) } } else if op.SORT() != nil { stmt.Operation = ast.ListOpSort @@ -470,10 +609,10 @@ func buildListOperationStatement(ctx parser.IListOperationStatementContext) *ast } exprs := op.AllExpression() if len(exprs) >= 1 { - stmt.OffsetExpr = buildExpression(exprs[0]) + stmt.OffsetExpr = buildSourceExpression(exprs[0]) } if len(exprs) >= 2 { - stmt.LimitExpr = buildExpression(exprs[1]) + stmt.LimitExpr = buildSourceExpression(exprs[1]) } } } @@ -537,7 +676,7 @@ func buildAggregateListStatement(ctx parser.IAggregateListStatementContext) *ast stmt.Operation = ast.AggregateSum if exprCtx := op.Expression(); exprCtx != nil { stmt.IsExpression = true - stmt.Expression = buildExpression(exprCtx) + stmt.Expression = buildSourceExpression(exprCtx) if v := op.VARIABLE(); v != nil { stmt.InputVariable = strings.TrimPrefix(v.GetText(), "$") } @@ -548,7 +687,7 @@ func buildAggregateListStatement(ctx parser.IAggregateListStatementContext) *ast stmt.Operation = ast.AggregateAverage if exprCtx := op.Expression(); exprCtx != nil { stmt.IsExpression = true - stmt.Expression = buildExpression(exprCtx) + stmt.Expression = buildSourceExpression(exprCtx) if v := op.VARIABLE(); v != nil { stmt.InputVariable = strings.TrimPrefix(v.GetText(), "$") } @@ -559,7 +698,7 @@ func buildAggregateListStatement(ctx parser.IAggregateListStatementContext) *ast stmt.Operation = ast.AggregateMinimum if exprCtx := op.Expression(); exprCtx != nil { stmt.IsExpression = true - stmt.Expression = buildExpression(exprCtx) + stmt.Expression = buildSourceExpression(exprCtx) if v := op.VARIABLE(); v != nil { stmt.InputVariable = strings.TrimPrefix(v.GetText(), "$") } @@ -570,7 +709,7 @@ func buildAggregateListStatement(ctx parser.IAggregateListStatementContext) *ast stmt.Operation = ast.AggregateMaximum if exprCtx := op.Expression(); exprCtx != nil { stmt.IsExpression = true - stmt.Expression = buildExpression(exprCtx) + stmt.Expression = buildSourceExpression(exprCtx) if v := op.VARIABLE(); v != nil { stmt.InputVariable = strings.TrimPrefix(v.GetText(), "$") } @@ -761,7 +900,7 @@ func buildShowPageArgList(ctx parser.IShowPageArgListContext) []ast.ShowPageArg // Widget-style: Param: $value spa.ParamName = identifierOrKeywordText(iok) if expr := arg.Expression(); expr != nil { - spa.Value = buildExpression(expr) + spa.Value = buildSourceExpression(expr) } } else { // Canonical: $Param = $value @@ -772,7 +911,7 @@ func buildShowPageArgList(ctx parser.IShowPageArgListContext) []ast.ShowPageArg if len(vars) >= 2 { spa.Value = &ast.VariableExpr{Name: strings.TrimPrefix(vars[1].GetText(), "$")} } else if expr := arg.Expression(); expr != nil { - spa.Value = buildExpression(expr) + spa.Value = buildSourceExpression(expr) } } @@ -795,7 +934,7 @@ func buildShowMessageStatement(ctx parser.IShowMessageStatementContext) *ast.Sho } if expr := smCtx.Expression(); expr != nil { - stmt.Message = buildExpression(expr) + stmt.Message = buildSourceExpression(expr) } if id := smCtx.IdentifierOrKeyword(); id != nil { @@ -805,8 +944,11 @@ func buildShowMessageStatement(ctx parser.IShowMessageStatementContext) *ast.Sho // Build template arguments (optional) if exprList := smCtx.ExpressionList(); exprList != nil { listCtx := exprList.(*parser.ExpressionListContext) - for _, expr := range listCtx.AllExpression() { - stmt.TemplateArgs = append(stmt.TemplateArgs, buildExpression(expr)) + allExprs := listCtx.AllExpression() + for i, expr := range allExprs { + value := buildSourceExpression(expr) + value = appendExpressionListTrailingWhitespace(listCtx, nextParserRuleContext(allExprs, i), expr, value) + stmt.TemplateArgs = append(stmt.TemplateArgs, value) } } @@ -830,14 +972,17 @@ func buildValidationFeedbackStatement(ctx parser.IValidationFeedbackStatementCon // Build message expression if msgExpr := vfCtx.Expression(); msgExpr != nil { - stmt.Message = buildExpression(msgExpr) + stmt.Message = buildSourceExpression(msgExpr) } // Build template arguments (optional) if exprList := vfCtx.ExpressionList(); exprList != nil { listCtx := exprList.(*parser.ExpressionListContext) - for _, expr := range listCtx.AllExpression() { - stmt.TemplateArgs = append(stmt.TemplateArgs, buildExpression(expr)) + allExprs := listCtx.AllExpression() + for i, expr := range allExprs { + value := buildSourceExpression(expr) + value = appendExpressionListTrailingWhitespace(listCtx, nextParserRuleContext(allExprs, i), expr, value) + stmt.TemplateArgs = append(stmt.TemplateArgs, value) } } @@ -932,7 +1077,7 @@ func buildRestCallStatement(ctx parser.IRestCallStatementContext) *ast.RestCallS Value: unquoteString(strLit.GetText()), } } else if expr := urlC.Expression(); expr != nil { - stmt.URL = buildExpression(expr) + stmt.URL = buildSourceExpression(expr) } } @@ -955,7 +1100,7 @@ func buildRestCallStatement(ctx parser.IRestCallStatementContext) *ast.RestCallS header.Name = unquoteString(strLit.GetText()) } if expr := hdrCtx.Expression(); expr != nil { - header.Value = buildExpression(expr) + header.Value = buildSourceExpression(expr) } stmt.Headers = append(stmt.Headers, header) } @@ -966,8 +1111,8 @@ func buildRestCallStatement(ctx parser.IRestCallStatementContext) *ast.RestCallS exprs := authCtx.AllExpression() if len(exprs) >= 2 { stmt.Auth = &ast.RestAuth{ - Username: buildExpression(exprs[0]), - Password: buildExpression(exprs[1]), + Username: buildSourceExpression(exprs[0]), + Password: buildSourceExpression(exprs[1]), } } } @@ -995,7 +1140,7 @@ func buildRestCallStatement(ctx parser.IRestCallStatementContext) *ast.RestCallS Value: unquoteString(strLit.GetText()), } } else if expr := bodyCtx.Expression(); expr != nil { - body.Template = buildExpression(expr) + body.Template = buildSourceExpression(expr) } // Get template parameters if tplParams := bodyCtx.TemplateParams(); tplParams != nil { @@ -1010,7 +1155,7 @@ func buildRestCallStatement(ctx parser.IRestCallStatementContext) *ast.RestCallS if timeoutClause := restCtx.RestCallTimeoutClause(); timeoutClause != nil { timeoutCtx := timeoutClause.(*parser.RestCallTimeoutClauseContext) if expr := timeoutCtx.Expression(); expr != nil { - stmt.Timeout = buildExpression(expr) + stmt.Timeout = buildSourceExpression(expr) } } diff --git a/mdl/visitor/visitor_microflow_statements.go b/mdl/visitor/visitor_microflow_statements.go index 3ecc322b..584e4506 100644 --- a/mdl/visitor/visitor_microflow_statements.go +++ b/mdl/visitor/visitor_microflow_statements.go @@ -5,6 +5,7 @@ package visitor import ( "strings" + "github.com/antlr4-go/antlr/v4" "github.com/mendixlabs/mxcli/mdl/ast" "github.com/mendixlabs/mxcli/mdl/grammar/parser" ) @@ -494,7 +495,7 @@ func buildDeclareStatement(ctx parser.IDeclareStatementContext) *ast.DeclareStmt // Get optional initial value if expr := declCtx.Expression(); expr != nil { - stmt.InitialValue = buildExpression(expr) + stmt.InitialValue = buildSourceExpression(expr) } return stmt @@ -517,9 +518,12 @@ func buildSetStatement(ctx parser.ISetStatementContext) ast.MicroflowStatement { targetVar = ap.GetText() } - // Get value expression + // Get value expression. Keep the structured expression for list/aggregate + // detection, then preserve source text for plain SET statements. var valueExpr ast.Expression + var valueExprCtx parser.IExpressionContext if expr := setCtx.Expression(); expr != nil { + valueExprCtx = expr valueExpr = buildExpression(expr) } @@ -654,6 +658,10 @@ func buildSetStatement(ctx parser.ISetStatementContext) ast.MicroflowStatement { } } + if valueExprCtx != nil { + valueExpr = buildSourceExpression(valueExprCtx) + } + // Default: regular SET statement return &ast.MfSetStmt{ Target: targetVar, @@ -946,7 +954,7 @@ func buildRetrieveStatement(ctx parser.IRetrieveStatementContext) *ast.RetrieveS stmt.Where = result } } else if expr := retrCtx.Expression(0); expr != nil { - stmt.Where = buildExpression(expr) + stmt.Where = buildSourceExpression(expr) } } @@ -1015,7 +1023,7 @@ func buildIfStatement(ctx parser.IIfStatementContext) *ast.IfStmt { // Get all expressions (condition for IF and ELSIFs) exprs := ifCtx.AllExpression() if len(exprs) > 0 { - stmt.Condition = buildExpression(exprs[0]) + stmt.Condition = buildSourceExpression(exprs[0]) } // Get all microflow bodies (THEN, ELSIF THENs, ELSE) @@ -1068,7 +1076,7 @@ func buildWhileStatement(ctx parser.IWhileStatementContext) *ast.WhileStmt { // Get condition expression if expr := wsCtx.Expression(); expr != nil { - stmt.Condition = buildExpression(expr) + stmt.Condition = buildSourceExpression(expr) } // Get body @@ -1079,6 +1087,54 @@ func buildWhileStatement(ctx parser.IWhileStatementContext) *ast.WhileStmt { return stmt } +func buildSourceExpression(ctx parser.IExpressionContext) ast.Expression { + if ctx == nil { + return nil + } + expr := buildExpression(ctx) + if prc, ok := ctx.(antlr.ParserRuleContext); ok { + if source := strings.TrimSpace(extractOriginalText(prc)); source != "" { + if shouldPreserveExpressionSource(source) { + return &ast.SourceExpr{Expression: expr, Source: source} + } + } + } + return expr +} + +func shouldPreserveExpressionSource(source string) bool { + if strings.ContainsAny(source, "\r\n") { + return true + } + inString := false + for i := 0; i < len(source); i++ { + if source[i] == '\'' { + if inString && i+1 < len(source) && source[i+1] == '\'' { + i++ + continue + } + inString = !inString + continue + } + if inString { + continue + } + switch source[i] { + case '=', '!', '<', '>', '+', '-', '*', ':', ',': + if i > 0 && source[i-1] != ' ' && source[i-1] != '\t' { + return true + } + if i+1 < len(source) && source[i+1] != ' ' && source[i+1] != '\t' && source[i+1] != '=' { + return true + } + } + } + if strings.Contains(strings.ToLower(source), "not(") { + return true + } + return false +} + // buildReturnStatement converts RETURN statement context to ReturnStmt. func buildReturnStatement(ctx parser.IReturnStatementContext) *ast.ReturnStmt { if ctx == nil { @@ -1090,7 +1146,7 @@ func buildReturnStatement(ctx parser.IReturnStatementContext) *ast.ReturnStmt { // Get optional return value if expr := retCtx.Expression(); expr != nil { - stmt.Value = buildExpression(expr) + stmt.Value = buildSourceExpression(expr) } return stmt diff --git a/mdl/visitor/visitor_test.go b/mdl/visitor/visitor_test.go index fb2e8be7..c8acac02 100644 --- a/mdl/visitor/visitor_test.go +++ b/mdl/visitor/visitor_test.go @@ -1589,3 +1589,145 @@ func TestCalculatedAttributeOnNonPersistentEntity(t *testing.T) { t.Error("Value attribute should be calculated") } } + +func TestDeclareAndLogTemplatePreserveMultilineSourceWhitespace(t *testing.T) { + input := `CREATE MICROFLOW Synthetic.Check () +RETURNS Boolean AS $Success +BEGIN + DECLARE $Endpoint String = @Synthetic.Endpoint ++ '/items?page=' + toString($Page) ++ '&token=' + (if $Token!=empty then $Token/Value +else +''); + LOG INFO NODE 'SyntheticLog' 'Processed {1} items for {2}' WITH ({1} = toString($Count) + +, {2} = $Endpoint); + RETURN true; +END;` + + prog, errs := Build(input) + if len(errs) > 0 { + t.Fatalf("unexpected parse errors: %v", errs) + } + + stmt := prog.Statements[0].(*ast.CreateMicroflowStmt) + decl, ok := stmt.Body[0].(*ast.DeclareStmt) + if !ok { + t.Fatalf("Expected DeclareStmt, got %T", stmt.Body[0]) + } + logStmt, ok := stmt.Body[1].(*ast.LogStmt) + if !ok { + t.Fatalf("Expected LogStmt, got %T", stmt.Body[1]) + } + + declSource, ok := decl.InitialValue.(*ast.SourceExpr) + if !ok { + t.Fatalf("Expected SourceExpr declare value, got %T", decl.InitialValue) + } + wantDecl := "@Synthetic.Endpoint\n+ '/items?page=' + toString($Page)\n+ '&token=' + (if $Token!=empty then $Token/Value\nelse\n'')" + if declSource.Source != wantDecl { + t.Fatalf("declare source = %q, want %q", declSource.Source, wantDecl) + } + + firstParam, ok := logStmt.Template[0].Value.(*ast.SourceExpr) + if !ok { + t.Fatalf("Expected SourceExpr first log template param, got %T", logStmt.Template[0].Value) + } + if firstParam.Source != "toString($Count)\n\n" { + t.Fatalf("template param source = %q, want trailing blank line", firstParam.Source) + } +} + +func TestActionExpressionSlotsPreserveSourceWhitespace(t *testing.T) { + input := `CREATE MICROFLOW Synthetic.PreserveExpressionSlots ( + $Input: String, + $Count: Integer, + $Flag: Boolean, + $OtherFlag: Boolean +) +RETURNS Boolean AS $Result +BEGIN + $Created = CREATE Synthetic.Entity (Name = if $Count=0 then 'zero' +else 'many' +, Description = 'sample'); + CHANGE $Created (Name = 'updated' +, Description = 'sample'); + $Loaded = CALL JAVA ACTION Synthetic.LoadValue(envVarName = 'SYNTHETIC_VALUE' +, defaultValue = @Synthetic.DefaultValue); + IF isMatch( $Input, '[0-9]+') THEN + SHOW MESSAGE 'Value {1}' TYPE Information OBJECTS [if $Flag then 'enabled' +else 'disabled' +, $Input]; + END IF; + RETURN $Flag +and +$OtherFlag; +END;` + + prog, errs := Build(input) + if len(errs) > 0 { + t.Fatalf("unexpected parse errors: %v", errs) + } + + mf := prog.Statements[0].(*ast.CreateMicroflowStmt) + + createStmt := mf.Body[0].(*ast.CreateObjectStmt) + if source, ok := createStmt.Changes[0].Value.(*ast.SourceExpr); !ok { + t.Fatalf("expected create assignment SourceExpr, got %T", createStmt.Changes[0].Value) + } else if !strings.Contains(source.Source, "\nelse 'many'\n") { + t.Fatalf("create assignment source lost line breaks: %q", source.Source) + } + + changeStmt := mf.Body[1].(*ast.ChangeObjectStmt) + if source, ok := changeStmt.Changes[0].Value.(*ast.SourceExpr); !ok { + t.Fatalf("expected change assignment SourceExpr, got %T", changeStmt.Changes[0].Value) + } else if !strings.Contains(source.Source, "\n") { + t.Fatalf("change assignment source lost trailing separator whitespace: %q", source.Source) + } + + callStmt := mf.Body[2].(*ast.CallJavaActionStmt) + if source, ok := callStmt.Arguments[0].Value.(*ast.SourceExpr); !ok { + t.Fatalf("expected call argument SourceExpr, got %T", callStmt.Arguments[0].Value) + } else if !strings.Contains(source.Source, "\n") { + t.Fatalf("call argument source lost trailing separator whitespace: %q", source.Source) + } + + ifStmt := mf.Body[3].(*ast.IfStmt) + if source, ok := ifStmt.Condition.(*ast.SourceExpr); !ok { + t.Fatalf("expected if condition SourceExpr, got %T", ifStmt.Condition) + } else if source.Source != "isMatch( $Input, '[0-9]+')" { + t.Fatalf("if condition source = %q", source.Source) + } + + showStmt := ifStmt.ThenBody[0].(*ast.ShowMessageStmt) + if source, ok := showStmt.TemplateArgs[0].(*ast.SourceExpr); !ok { + t.Fatalf("expected show-message argument SourceExpr, got %T", showStmt.TemplateArgs[0]) + } else if !strings.Contains(source.Source, "\nelse 'disabled'\n") { + t.Fatalf("show-message argument source lost line breaks: %q", source.Source) + } + + returnStmt := mf.Body[4].(*ast.ReturnStmt) + if source, ok := returnStmt.Value.(*ast.SourceExpr); !ok { + t.Fatalf("expected return SourceExpr, got %T", returnStmt.Value) + } else if source.Source != "$Flag\nand\n$OtherFlag" { + t.Fatalf("return source = %q", source.Source) + } +} + +func TestShouldPreserveExpressionSourceIgnoresStringLiteralPunctuation(t *testing.T) { + if shouldPreserveExpressionSource("'Processed {1} items!'") { + t.Fatal("plain string literal punctuation should not force SourceExpr preservation") + } + if shouldPreserveExpressionSource("'Owner''s item: {1}'") { + t.Fatal("escaped quotes and colon inside a string literal should not force SourceExpr preservation") + } + if !shouldPreserveExpressionSource("$Token!=empty") { + t.Fatal("compact operators outside string literals should preserve source") + } + if !shouldPreserveExpressionSource("substring($Text,0,find($Text, '.'))") { + t.Fatal("compact comma-separated arguments should preserve source") + } + if !shouldPreserveExpressionSource("not($Object/Flag)") { + t.Fatal("compact not() expressions should preserve source") + } +}