From 1f46ab21792c899bb99ebd05b695ac6b3fac6b01 Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Thu, 11 Dec 2025 03:32:20 +0100 Subject: [PATCH 01/71] LDEV-5977 fix AST ternary alternate using wrong branch --- .../transformer/bytecode/op/OpContional.java | 2 +- test/tickets/LDEV5977.cfc | 53 +++++++++++++++++++ test/tickets/LDEV5977/ternary.cfm | 3 ++ 3 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 test/tickets/LDEV5977.cfc create mode 100644 test/tickets/LDEV5977/ternary.cfm diff --git a/core/src/main/java/lucee/transformer/bytecode/op/OpContional.java b/core/src/main/java/lucee/transformer/bytecode/op/OpContional.java index a740798df6a..827048acb6b 100755 --- a/core/src/main/java/lucee/transformer/bytecode/op/OpContional.java +++ b/core/src/main/java/lucee/transformer/bytecode/op/OpContional.java @@ -105,7 +105,7 @@ public void dump(Struct sct) { // alternate { Struct alternate = new StructImpl(Struct.TYPE_LINKED); - left.dump(alternate); + right.dump(alternate); sct.setEL(KeyConstants._alternate, alternate); } } diff --git a/test/tickets/LDEV5977.cfc b/test/tickets/LDEV5977.cfc new file mode 100644 index 00000000000..f293e6b7e57 --- /dev/null +++ b/test/tickets/LDEV5977.cfc @@ -0,0 +1,53 @@ +component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { + + variables.testDir = getDirectoryFromPath( getCurrentTemplatePath() ) & "LDEV5977/"; + + function run( testResults, testBox ) { + + describe( "LDEV-5977: Ternary expression alternate value duplicates consequent", function() { + + it( "ternary alternate should be different from consequent", function() { + var code = fileRead( variables.testDir & "ternary.cfm" ); + var ast = astFromString( code, "cfml" ); + + // Find the ConditionalExpression + var condExpr = findNodeByType( ast, "ConditionalExpression" ); + expect( condExpr ).notToBeNull( "ConditionalExpression should be present" ); + + // Get consequent and alternate + var consequent = condExpr.consequent; + var alternate = condExpr.alternate; + + expect( consequent ).notToBeNull( "Consequent should be present" ); + expect( alternate ).notToBeNull( "Alternate should be present" ); + + // Consequent should be "B", alternate should be "C" + expect( consequent.name ).toBe( "B", "Consequent should be B" ); + expect( alternate.name ).toBe( "C", "Alternate should be C, not duplicating consequent" ); + }); + + }); + } + + private function findNodeByType( required struct node, required string nodeType ) { + if ( ( node.type ?: "" ) == arguments.nodeType ) { + return node; + } + for ( var key in node ) { + var val = node[ key ]; + if ( isStruct( val ) ) { + var result = findNodeByType( val, arguments.nodeType ); + if ( !isNull( result ) ) return result; + } else if ( isArray( val ) ) { + for ( var item in val ) { + if ( isStruct( item ) ) { + var result = findNodeByType( item, arguments.nodeType ); + if ( !isNull( result ) ) return result; + } + } + } + } + return; + } + +} diff --git a/test/tickets/LDEV5977/ternary.cfm b/test/tickets/LDEV5977/ternary.cfm new file mode 100644 index 00000000000..53754207f1e --- /dev/null +++ b/test/tickets/LDEV5977/ternary.cfm @@ -0,0 +1,3 @@ + +x = a ? b : c; + From 9c15bb96909099aa393078271c924e1e5a6b6273 Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Thu, 11 Dec 2025 03:32:51 +0100 Subject: [PATCH 02/71] LDEV-5969 preserve tag attributes in AST after removeAttribute() --- .../bytecode/statement/tag/TagBase.java | 10 +- test/tickets/LDEV5969.cfc | 151 ++++++++++++++++++ test/tickets/LDEV5969/customAttrs.cfm | 4 + test/tickets/LDEV5969/debug_ast.cfm | 5 + test/tickets/LDEV5969/multipleArgs.cfm | 5 + test/tickets/LDEV5969/script_func.cfm | 5 + test/tickets/LDEV5969/simple.cfm | 3 + test/tickets/LDEV5969/withArgument.cfm | 4 + 8 files changed, 185 insertions(+), 2 deletions(-) create mode 100644 test/tickets/LDEV5969.cfc create mode 100644 test/tickets/LDEV5969/customAttrs.cfm create mode 100644 test/tickets/LDEV5969/debug_ast.cfm create mode 100644 test/tickets/LDEV5969/multipleArgs.cfm create mode 100644 test/tickets/LDEV5969/script_func.cfm create mode 100644 test/tickets/LDEV5969/simple.cfm create mode 100644 test/tickets/LDEV5969/withArgument.cfm diff --git a/core/src/main/java/lucee/transformer/bytecode/statement/tag/TagBase.java b/core/src/main/java/lucee/transformer/bytecode/statement/tag/TagBase.java index 0e6bb287601..1a065af647f 100755 --- a/core/src/main/java/lucee/transformer/bytecode/statement/tag/TagBase.java +++ b/core/src/main/java/lucee/transformer/bytecode/statement/tag/TagBase.java @@ -55,6 +55,7 @@ public abstract class TagBase extends StatementBase implements Tag { private boolean scriptBase = false; private Map metadata; + private Map sourceAttributes; // original attributes before removeAttribute() calls // private Label finallyLabel; public TagBase(Factory factory, Position start, Position end) { @@ -149,6 +150,10 @@ public Attribute getAttribute(String name) { @Override public Attribute removeAttribute(String name) { + // Save original attributes on first removal (for AST dump) + if (sourceAttributes == null) { + sourceAttributes = new LinkedHashMap(attributes); + } return attributes.remove(name); } @@ -209,10 +214,11 @@ public void dump(Struct sct) { if (appendix != null) sct.setEL(KeyConstants._appendix, appendix); if (fullname != null) sct.setEL(KeyConstants._fullname, fullname); - // attributes + // attributes (use sourceAttributes if available, as removeAttribute() may have removed some) Array arrAttrs = new ArrayImpl(); sct.setEL(KeyConstants._attributes, arrAttrs); - for (Entry entry: attributes.entrySet()) { + Map attrsToUse = sourceAttributes != null ? sourceAttributes : attributes; + for (Entry entry: attrsToUse.entrySet()) { Attribute attr = entry.getValue(); Struct sctAttr = new StructImpl(Struct.TYPE_LINKED); arrAttrs.appendEL(sctAttr); diff --git a/test/tickets/LDEV5969.cfc b/test/tickets/LDEV5969.cfc new file mode 100644 index 00000000000..8e34538c71c --- /dev/null +++ b/test/tickets/LDEV5969.cfc @@ -0,0 +1,151 @@ +component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { + + variables.testDir = getDirectoryFromPath( getCurrentTemplatePath() ) & "LDEV5969/"; + + function run( testResults, testBox ) { + + describe( "LDEV-5969: cffunction/cfargument attributes missing from AST", function() { + + it( "cffunction attributes should be captured in AST", function() { + var code = fileRead( variables.testDir & "simple.cfm" ); + var ast = astFromString( code, "tag" ); + + // Find the cffunction tag in the AST + var funcTag = findTagByName( ast, "function" ); + expect( funcTag ).notToBeNull( "cffunction tag should be present in AST" ); + + // Verify attributes are present + var attrs = funcTag.attributes; + expect( attrs ).toBeArray(); + expect( arrayLen( attrs ) ).toBeGTE( 4, "Should have at least 4 attributes (name, access, returntype, output)" ); + + // Check specific attributes exist + var attrNames = attrs.map( function( a ) { return a.name; } ); + expect( attrNames ).toInclude( "name" ); + expect( attrNames ).toInclude( "access" ); + expect( attrNames ).toInclude( "returntype" ); + expect( attrNames ).toInclude( "output" ); + }); + + it( "cfargument attributes should be captured in AST", function() { + var code = fileRead( variables.testDir & "withArgument.cfm" ); + var ast = astFromString( code, "tag" ); + + // Find the cfargument tag in the AST + var argTag = findTagByName( ast, "argument" ); + expect( argTag ).notToBeNull( "cfargument tag should be present in AST" ); + + // Verify attributes are present + var attrs = argTag.attributes; + expect( attrs ).toBeArray(); + expect( arrayLen( attrs ) ).toBeGTE( 4, "Should have at least 4 attributes (name, type, required, default)" ); + + // Check specific attributes exist + var attrNames = attrs.map( function( a ) { return a.name; } ); + expect( attrNames ).toInclude( "name" ); + expect( attrNames ).toInclude( "type" ); + expect( attrNames ).toInclude( "required" ); + expect( attrNames ).toInclude( "default" ); + }); + + it( "cffunction with multiple cfarguments should capture all argument attributes", function() { + var code = fileRead( variables.testDir & "multipleArgs.cfm" ); + var ast = astFromString( code, "tag" ); + + // Find all cfargument tags + var argTags = findAllTagsByName( ast, "argument" ); + expect( arrayLen( argTags ) ).toBe( 2, "Should have 2 cfargument tags" ); + + // First argument should have name="firstName" + var firstArg = argTags[ 1 ]; + var firstArgNames = firstArg.attributes.map( function( a ) { return a.name; } ); + expect( firstArgNames ).toInclude( "name" ); + + // Second argument should have default attribute + var secondArg = argTags[ 2 ]; + var secondArgNames = secondArg.attributes.map( function( a ) { return a.name; } ); + expect( secondArgNames ).toInclude( "default" ); + }); + + it( "custom/metadata attributes should be captured on cffunction and cfargument", function() { + var code = fileRead( variables.testDir & "customAttrs.cfm" ); + var ast = astFromString( code, "tag" ); + + // Check cffunction custom attributes + var funcTag = findTagByName( ast, "function" ); + expect( funcTag ).notToBeNull(); + var funcAttrNames = funcTag.attributes.map( function( a ) { return a.name; } ); + expect( funcAttrNames ).toInclude( "customattr", "Custom attribute should be preserved on cffunction" ); + expect( funcAttrNames ).toInclude( "anotherattr", "Another custom attribute should be preserved on cffunction" ); + + // Check cfargument custom attributes + var argTag = findTagByName( ast, "argument" ); + expect( argTag ).notToBeNull(); + var argAttrNames = argTag.attributes.map( function( a ) { return a.name; } ); + expect( argAttrNames ).toInclude( "mymeta", "Custom attribute should be preserved on cfargument" ); + expect( argAttrNames ).toInclude( "extrainfo", "Another custom attribute should be preserved on cfargument" ); + }); + + }); + } + + /** + * Recursively find a tag by name in the AST + */ + private function findTagByName( required struct node, required string tagName ) { + if ( ( node.type ?: "" ) == "CFMLTag" && ( node.name ?: "" ) == arguments.tagName ) { + return node; + } + if ( structKeyExists( node, "body" ) ) { + if ( isArray( node.body ) ) { + for ( var child in node.body ) { + if ( isStruct( child ) ) { + var result = findTagByName( child, arguments.tagName ); + if ( !isNull( result ) ) return result; + } + } + } else if ( isStruct( node.body ) ) { + var result = findTagByName( node.body, arguments.tagName ); + if ( !isNull( result ) ) return result; + } + } + if ( structKeyExists( node, "children" ) && isArray( node.children ) ) { + for ( var child in node.children ) { + if ( isStruct( child ) ) { + var result = findTagByName( child, arguments.tagName ); + if ( !isNull( result ) ) return result; + } + } + } + return; + } + + /** + * Recursively find all tags by name in the AST + */ + private function findAllTagsByName( required struct node, required string tagName, array results = [] ) { + if ( ( node.type ?: "" ) == "CFMLTag" && ( node.name ?: "" ) == arguments.tagName ) { + arrayAppend( results, node ); + } + if ( structKeyExists( node, "body" ) ) { + if ( isArray( node.body ) ) { + for ( var child in node.body ) { + if ( isStruct( child ) ) { + findAllTagsByName( child, arguments.tagName, results ); + } + } + } else if ( isStruct( node.body ) ) { + findAllTagsByName( node.body, arguments.tagName, results ); + } + } + if ( structKeyExists( node, "children" ) && isArray( node.children ) ) { + for ( var child in node.children ) { + if ( isStruct( child ) ) { + findAllTagsByName( child, arguments.tagName, results ); + } + } + } + return results; + } + +} diff --git a/test/tickets/LDEV5969/customAttrs.cfm b/test/tickets/LDEV5969/customAttrs.cfm new file mode 100644 index 00000000000..5d3a3f23f83 --- /dev/null +++ b/test/tickets/LDEV5969/customAttrs.cfm @@ -0,0 +1,4 @@ + + + + diff --git a/test/tickets/LDEV5969/debug_ast.cfm b/test/tickets/LDEV5969/debug_ast.cfm new file mode 100644 index 00000000000..60279719cbd --- /dev/null +++ b/test/tickets/LDEV5969/debug_ast.cfm @@ -0,0 +1,5 @@ + +code = fileRead( getCurrentTemplatePath().replace( "debug_ast.cfm", "withArgument.cfm" ) ); +ast = astFromString( code, "tag" ); +systemOutput( serialize( ast ), true ); + diff --git a/test/tickets/LDEV5969/multipleArgs.cfm b/test/tickets/LDEV5969/multipleArgs.cfm new file mode 100644 index 00000000000..865883054ea --- /dev/null +++ b/test/tickets/LDEV5969/multipleArgs.cfm @@ -0,0 +1,5 @@ + + + + + diff --git a/test/tickets/LDEV5969/script_func.cfm b/test/tickets/LDEV5969/script_func.cfm new file mode 100644 index 00000000000..ff7bf37564b --- /dev/null +++ b/test/tickets/LDEV5969/script_func.cfm @@ -0,0 +1,5 @@ + +public string function testFunc( required string name, numeric age = 0 ) { + return name; +} + diff --git a/test/tickets/LDEV5969/simple.cfm b/test/tickets/LDEV5969/simple.cfm new file mode 100644 index 00000000000..1b237b170f1 --- /dev/null +++ b/test/tickets/LDEV5969/simple.cfm @@ -0,0 +1,3 @@ + + + diff --git a/test/tickets/LDEV5969/withArgument.cfm b/test/tickets/LDEV5969/withArgument.cfm new file mode 100644 index 00000000000..2e55f2fd93f --- /dev/null +++ b/test/tickets/LDEV5969/withArgument.cfm @@ -0,0 +1,4 @@ + + + + From e26fd39d71916a1133b99f200124345eeef6a7ec Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Thu, 11 Dec 2025 03:33:05 +0100 Subject: [PATCH 03/71] LDEV-5975 add test for chained method calls in AST --- test/tickets/LDEV5975.cfc | 112 +++++++++++++++++++++ test/tickets/LDEV5975/chainedCalls.cfm | 3 + test/tickets/LDEV5975/methodCall.cfm | 1 + test/tickets/LDEV5975/simpleMethodCall.cfm | 3 + 4 files changed, 119 insertions(+) create mode 100644 test/tickets/LDEV5975.cfc create mode 100644 test/tickets/LDEV5975/chainedCalls.cfm create mode 100644 test/tickets/LDEV5975/methodCall.cfm create mode 100644 test/tickets/LDEV5975/simpleMethodCall.cfm diff --git a/test/tickets/LDEV5975.cfc b/test/tickets/LDEV5975.cfc new file mode 100644 index 00000000000..38fcbfb44df --- /dev/null +++ b/test/tickets/LDEV5975.cfc @@ -0,0 +1,112 @@ +component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { + + variables.testDir = getDirectoryFromPath( getCurrentTemplatePath() ) & "LDEV5975/"; + + function run( testResults, testBox ) { + + describe( "LDEV-5975: Chained method calls lose method name in AST", function() { + + it( "simple method call should include method name in AST", function() { + var code = fileRead( variables.testDir & "simpleMethodCall.cfm" ); + var ast = astFromString( code, "cfml" ); + + // Find the CallExpression + var callExpr = findNodeByType( ast, "CallExpression" ); + expect( callExpr ).notToBeNull( "CallExpression should be present" ); + + // The callee should be a MemberExpression with object and property + var callee = callExpr.callee; + expect( callee.type ).toBe( "MemberExpression", "Callee should be a MemberExpression" ); + expect( callee.object.type ).toBe( "Identifier" ); + expect( callee.object.name ).toBe( "OBJ" ); + expect( callee.property.type ).toBe( "Identifier" ); + expect( callee.property.name ).toBe( "MYMETHOD", "Method name should be preserved" ); + }); + + it( "method call with arguments should include method name", function() { + var code = fileRead( variables.testDir & "methodCall.cfm" ); + var ast = astFromString( code, "tag" ); + + // Find the CallExpression for ensureCapacity + var callExpr = findNodeByType( ast, "CallExpression" ); + expect( callExpr ).notToBeNull( "CallExpression should be present" ); + + // The callee should be a MemberExpression + var callee = callExpr.callee; + expect( callee.type ).toBe( "MemberExpression", "Callee should be a MemberExpression" ); + + // Should have method name "ensureCapacity" + expect( callee.property.name ).toBe( "ENSURECAPACITY", "Method name 'ensureCapacity' should be preserved" ); + }); + + it( "chained method calls should preserve all method names", function() { + var code = fileRead( variables.testDir & "chainedCalls.cfm" ); + var ast = astFromString( code, "cfml" ); + + // Find all method names in the AST + var methodNames = findAllMethodNames( ast ); + + // Should contain first, second, third + expect( methodNames ).toInclude( "FIRST", "Method 'first' should be preserved" ); + expect( methodNames ).toInclude( "SECOND", "Method 'second' should be preserved" ); + expect( methodNames ).toInclude( "THIRD", "Method 'third' should be preserved" ); + }); + + }); + } + + /** + * Recursively find a node by type in the AST + */ + private function findNodeByType( required struct node, required string nodeType ) { + if ( ( node.type ?: "" ) == arguments.nodeType ) { + return node; + } + for ( var key in node ) { + var val = node[ key ]; + if ( isStruct( val ) ) { + var result = findNodeByType( val, arguments.nodeType ); + if ( !isNull( result ) ) return result; + } else if ( isArray( val ) ) { + for ( var item in val ) { + if ( isStruct( item ) ) { + var result = findNodeByType( item, arguments.nodeType ); + if ( !isNull( result ) ) return result; + } + } + } + } + return; + } + + /** + * Find all method names from MemberExpression.property in CallExpressions + */ + private function findAllMethodNames( required struct node, array results = [] ) { + // If this is a CallExpression with MemberExpression callee, grab the method name + if ( ( node.type ?: "" ) == "CallExpression" && structKeyExists( node, "callee" ) && isStruct( node.callee ) ) { + if ( ( node.callee.type ?: "" ) == "MemberExpression" && structKeyExists( node.callee, "property" ) ) { + var prop = node.callee.property; + if ( isStruct( prop ) && structKeyExists( prop, "name" ) ) { + arrayAppend( results, prop.name ); + } + } + } + + // Recurse + for ( var key in node ) { + var val = node[ key ]; + if ( isStruct( val ) ) { + findAllMethodNames( val, results ); + } else if ( isArray( val ) ) { + for ( var item in val ) { + if ( isStruct( item ) ) { + findAllMethodNames( item, results ); + } + } + } + } + return results; + } + +} diff --git a/test/tickets/LDEV5975/chainedCalls.cfm b/test/tickets/LDEV5975/chainedCalls.cfm new file mode 100644 index 00000000000..20178bc90c7 --- /dev/null +++ b/test/tickets/LDEV5975/chainedCalls.cfm @@ -0,0 +1,3 @@ + +x = obj.first().second().third(); + diff --git a/test/tickets/LDEV5975/methodCall.cfm b/test/tickets/LDEV5975/methodCall.cfm new file mode 100644 index 00000000000..430c1e025d9 --- /dev/null +++ b/test/tickets/LDEV5975/methodCall.cfm @@ -0,0 +1 @@ + diff --git a/test/tickets/LDEV5975/simpleMethodCall.cfm b/test/tickets/LDEV5975/simpleMethodCall.cfm new file mode 100644 index 00000000000..385f633dd92 --- /dev/null +++ b/test/tickets/LDEV5975/simpleMethodCall.cfm @@ -0,0 +1,3 @@ + +x = obj.myMethod(); + From eb9cc45a4501bb84d5de8a306a0b19661e417a0f Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Thu, 11 Dec 2025 03:33:20 +0100 Subject: [PATCH 04/71] LDEV-5978 add test for queryExecute extra arg in AST --- test/tickets/LDEV5978.cfc | 50 ++++++++++++++++++++++++++ test/tickets/LDEV5978/queryExecute.cfm | 3 ++ 2 files changed, 53 insertions(+) create mode 100644 test/tickets/LDEV5978.cfc create mode 100644 test/tickets/LDEV5978/queryExecute.cfm diff --git a/test/tickets/LDEV5978.cfc b/test/tickets/LDEV5978.cfc new file mode 100644 index 00000000000..4b3cdbb916e --- /dev/null +++ b/test/tickets/LDEV5978.cfc @@ -0,0 +1,50 @@ +component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { + + variables.testDir = getDirectoryFromPath( getCurrentTemplatePath() ) & "LDEV5978/"; + + function run( testResults, testBox ) { + + describe( "LDEV-5978: queryExecute adds extra variable name argument in AST", function() { + + it( "queryExecute with 1 argument should have 1 argument in AST", function() { + var code = fileRead( variables.testDir & "queryExecute.cfm" ); + var ast = astFromString( code, "cfml" ); + + // Find the CallExpression for queryExecute + var callExpr = findCallByName( ast, "QUERYEXECUTE" ); + expect( callExpr ).notToBeNull( "queryExecute CallExpression should be present" ); + + // Should have exactly 1 argument (the SQL string) + var args = callExpr.arguments; + expect( args ).toBeArray(); + expect( arrayLen( args ) ).toBe( 1, "queryExecute should have 1 argument, not extra internal arguments" ); + }); + + }); + } + + private function findCallByName( required struct node, required string funcName ) { + if ( ( node.type ?: "" ) == "CallExpression" ) { + var callee = node.callee ?: {}; + if ( ( callee.name ?: "" ) == arguments.funcName ) { + return node; + } + } + for ( var key in node ) { + var val = node[ key ]; + if ( isStruct( val ) ) { + var result = findCallByName( val, arguments.funcName ); + if ( !isNull( result ) ) return result; + } else if ( isArray( val ) ) { + for ( var item in val ) { + if ( isStruct( item ) ) { + var result = findCallByName( item, arguments.funcName ); + if ( !isNull( result ) ) return result; + } + } + } + } + return; + } + +} diff --git a/test/tickets/LDEV5978/queryExecute.cfm b/test/tickets/LDEV5978/queryExecute.cfm new file mode 100644 index 00000000000..e2b78e727ee --- /dev/null +++ b/test/tickets/LDEV5978/queryExecute.cfm @@ -0,0 +1,3 @@ + +q = queryExecute("SELECT 1"); + From 638cf922b45e86c8b341bfacd96c3b2b3e342bc4 Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Thu, 11 Dec 2025 03:33:36 +0100 Subject: [PATCH 05/71] LDEV-5979 add test for isDefined extra arg in AST --- test/tickets/LDEV5979.cfc | 54 +++++++++++++++++++++++++++++ test/tickets/LDEV5979/isDefined.cfm | 3 ++ 2 files changed, 57 insertions(+) create mode 100644 test/tickets/LDEV5979.cfc create mode 100644 test/tickets/LDEV5979/isDefined.cfm diff --git a/test/tickets/LDEV5979.cfc b/test/tickets/LDEV5979.cfc new file mode 100644 index 00000000000..ea63c3dab6a --- /dev/null +++ b/test/tickets/LDEV5979.cfc @@ -0,0 +1,54 @@ +component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { + + variables.testDir = getDirectoryFromPath( getCurrentTemplatePath() ) & "LDEV5979/"; + + function run( testResults, testBox ) { + + describe( "LDEV-5979: isDefined adds extra scope argument in AST", function() { + + it( "isDefined with 1 argument should have 1 argument in AST", function() { + var code = fileRead( variables.testDir & "isDefined.cfm" ); + var ast = astFromString( code, "cfml" ); + + // Find the CallExpression for isDefined + var callExpr = findCallByName( ast, "ISDEFINED" ); + expect( callExpr ).notToBeNull( "isDefined CallExpression should be present" ); + + // Should have exactly 1 argument (the variable name string) + var args = callExpr.arguments; + expect( args ).toBeArray(); + expect( arrayLen( args ) ).toBe( 1, "isDefined should have 1 argument, not extra internal scope argument" ); + + // The argument should be the string "foo", not a number + var firstArg = args[ 1 ]; + expect( firstArg.type ).toBe( "StringLiteral", "First argument should be a StringLiteral" ); + }); + + }); + } + + private function findCallByName( required struct node, required string funcName ) { + if ( ( node.type ?: "" ) == "CallExpression" ) { + var callee = node.callee ?: {}; + if ( ( callee.name ?: "" ) == arguments.funcName ) { + return node; + } + } + for ( var key in node ) { + var val = node[ key ]; + if ( isStruct( val ) ) { + var result = findCallByName( val, arguments.funcName ); + if ( !isNull( result ) ) return result; + } else if ( isArray( val ) ) { + for ( var item in val ) { + if ( isStruct( item ) ) { + var result = findCallByName( item, arguments.funcName ); + if ( !isNull( result ) ) return result; + } + } + } + } + return; + } + +} diff --git a/test/tickets/LDEV5979/isDefined.cfm b/test/tickets/LDEV5979/isDefined.cfm new file mode 100644 index 00000000000..ec9b62562aa --- /dev/null +++ b/test/tickets/LDEV5979/isDefined.cfm @@ -0,0 +1,3 @@ + +x = isDefined("foo"); + From 07ff462d9c1b336c25fb70002d6becd6f7a59f4b Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Thu, 11 Dec 2025 03:33:47 +0100 Subject: [PATCH 06/71] LDEV-5980 add test for BIF internal metadata in AST --- test/tickets/LDEV5980.cfc | 74 +++++++++++++++++++++++++++++ test/tickets/LDEV5980/debug_ast.cfm | 5 ++ test/tickets/LDEV5980/dump.cfm | 3 ++ test/tickets/LDEV5980/throw.cfm | 3 ++ 4 files changed, 85 insertions(+) create mode 100644 test/tickets/LDEV5980.cfc create mode 100644 test/tickets/LDEV5980/debug_ast.cfm create mode 100644 test/tickets/LDEV5980/dump.cfm create mode 100644 test/tickets/LDEV5980/throw.cfm diff --git a/test/tickets/LDEV5980.cfc b/test/tickets/LDEV5980.cfc new file mode 100644 index 00000000000..3d6ebef3274 --- /dev/null +++ b/test/tickets/LDEV5980.cfc @@ -0,0 +1,74 @@ +component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { + + variables.testDir = getDirectoryFromPath( getCurrentTemplatePath() ) & "LDEV5980/"; + + function run( testResults, testBox ) { + + describe( "LDEV-5980: BIF calls keep adding internal metadata", function() { + + it( "dump() should not have internal metadata arguments in AST", function() { + var code = fileRead( variables.testDir & "dump.cfm" ); + var ast = astFromString( code, "tag" ); + + // Find the CallExpression for dump + var callExpr = findCallExpression( ast, "DUMP" ); + expect( callExpr ).notToBeNull( "dump CallExpression should be present" ); + + var args = callExpr.arguments; + + // dump(myVar) should have exactly 1 argument + expect( arrayLen( args ) ).toBe( 1, "dump() should have 1 argument, not internal metadata" ); + + // The argument should be the identifier MYVAR, not named params like __filename + var firstArg = args[ 1 ]; + expect( firstArg.type ).toBe( "Identifier", "Argument should be an Identifier" ); + expect( firstArg.name ).toBe( "MYVAR", "Argument should be MYVAR" ); + }); + + it( "throw() should not have internal metadata arguments in AST", function() { + var code = fileRead( variables.testDir & "throw.cfm" ); + var ast = astFromString( code, "tag" ); + + // Find the CallExpression for throw + var callExpr = findCallExpression( ast, "THROW" ); + expect( callExpr ).notToBeNull( "throw CallExpression should be present" ); + + var args = callExpr.arguments; + + // throw("error message") should have exactly 1 argument + expect( arrayLen( args ) ).toBe( 1, "throw() should have 1 argument, not internal metadata" ); + }); + + }); + } + + /** + * Recursively find a CallExpression by callee name + */ + private function findCallExpression( required struct node, required string funcName ) { + if ( ( node.type ?: "" ) == "CallExpression" ) { + var callee = node.callee ?: {}; + // Direct function call + if ( ( callee.type ?: "" ) == "Identifier" && ( callee.name ?: "" ) == arguments.funcName ) { + return node; + } + } + + for ( var key in node ) { + var val = node[ key ]; + if ( isStruct( val ) ) { + var result = findCallExpression( val, arguments.funcName ); + if ( !isNull( result ) ) return result; + } else if ( isArray( val ) ) { + for ( var item in val ) { + if ( isStruct( item ) ) { + var result = findCallExpression( item, arguments.funcName ); + if ( !isNull( result ) ) return result; + } + } + } + } + return; + } + +} diff --git a/test/tickets/LDEV5980/debug_ast.cfm b/test/tickets/LDEV5980/debug_ast.cfm new file mode 100644 index 00000000000..87bc51d21a1 --- /dev/null +++ b/test/tickets/LDEV5980/debug_ast.cfm @@ -0,0 +1,5 @@ + +code = fileRead( getCurrentTemplatePath().replace("debug_ast.cfm", "dump.cfm") ); +ast = astFromString( code, "tag" ); +systemOutput( serialize( ast ), true ); + diff --git a/test/tickets/LDEV5980/dump.cfm b/test/tickets/LDEV5980/dump.cfm new file mode 100644 index 00000000000..472b660363f --- /dev/null +++ b/test/tickets/LDEV5980/dump.cfm @@ -0,0 +1,3 @@ + +dump(myVar); + diff --git a/test/tickets/LDEV5980/throw.cfm b/test/tickets/LDEV5980/throw.cfm new file mode 100644 index 00000000000..323f24624bd --- /dev/null +++ b/test/tickets/LDEV5980/throw.cfm @@ -0,0 +1,3 @@ + +throw("error message"); + From 57b2d634c95879c9ecda554905cc6a842f00da3c Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Thu, 11 Dec 2025 03:35:35 +0100 Subject: [PATCH 07/71] LDEV-5975 preserve original arguments in AST before evaluator modifications Also fixes LDEV-5978, LDEV-5979, LDEV-5980 --- .../bytecode/expression/var/ArgumentImpl.java | 12 ++++++++- .../expression/var/FunctionMember.java | 25 +++++++++++++++++++ .../bytecode/expression/var/VariableImpl.java | 17 ++++++++++--- .../expression/AbstrCFMLExprTransformer.java | 10 +++++++- 4 files changed, 59 insertions(+), 5 deletions(-) diff --git a/core/src/main/java/lucee/transformer/bytecode/expression/var/ArgumentImpl.java b/core/src/main/java/lucee/transformer/bytecode/expression/var/ArgumentImpl.java index ff2acb176d4..5e2e5d33bcf 100755 --- a/core/src/main/java/lucee/transformer/bytecode/expression/var/ArgumentImpl.java +++ b/core/src/main/java/lucee/transformer/bytecode/expression/var/ArgumentImpl.java @@ -31,6 +31,7 @@ public class ArgumentImpl extends ExpressionBase implements Argument { private Expression raw; private String type; + private Expression sourceRaw; public ArgumentImpl(Expression value, String type) { super(value.getFactory(), value.getStart(), value.getEnd()); @@ -90,6 +91,15 @@ public String getStringType() { @Override public void dump(Struct sct) { - raw.dump(sct); + getSourceRawValue().dump(sct); } + + public void snapshotSourceValue() { + if (sourceRaw == null) sourceRaw = raw; + } + + public Expression getSourceRawValue() { + return sourceRaw != null ? sourceRaw : raw; + } + } \ No newline at end of file diff --git a/core/src/main/java/lucee/transformer/bytecode/expression/var/FunctionMember.java b/core/src/main/java/lucee/transformer/bytecode/expression/var/FunctionMember.java index 9ed8e9a1069..cf6f117b5a9 100755 --- a/core/src/main/java/lucee/transformer/bytecode/expression/var/FunctionMember.java +++ b/core/src/main/java/lucee/transformer/bytecode/expression/var/FunctionMember.java @@ -32,6 +32,7 @@ public abstract class FunctionMember implements NamedMember, Member, Func { private Variable parent; private boolean safeNavigated; private Expression safeNavigatedValue; + private Argument[] sourceArguments; // original arguments before evaluators modify them @Override public final void setParent(Variable parent) { @@ -88,4 +89,28 @@ public void setSafeNavigatedValue(Expression safeNavigatedValue) { public Expression getSafeNavigatedValue() { return safeNavigatedValue; } + + /** + * Snapshots the current arguments as the source arguments. + * This should be called after parsing but before evaluators modify arguments. + */ + public void snapshotSourceArguments() { + if (sourceArguments == null && arguments.length > 0) { + sourceArguments = arguments.clone(); + // Also snapshot each argument's value in case evaluators modify via setValue() + for (Argument arg : sourceArguments) { + if (arg instanceof ArgumentImpl) { + ((ArgumentImpl) arg).snapshotSourceValue(); + } + } + } + } + + /** + * Returns the source arguments (original arguments before evaluator modifications). + * If no snapshot was taken, returns the current arguments. + */ + public Argument[] getSourceArguments() { + return sourceArguments != null ? sourceArguments : arguments; + } } \ No newline at end of file diff --git a/core/src/main/java/lucee/transformer/bytecode/expression/var/VariableImpl.java b/core/src/main/java/lucee/transformer/bytecode/expression/var/VariableImpl.java index aa19ea9e42f..c1901d8645e 100644 --- a/core/src/main/java/lucee/transformer/bytecode/expression/var/VariableImpl.java +++ b/core/src/main/java/lucee/transformer/bytecode/expression/var/VariableImpl.java @@ -1150,14 +1150,25 @@ private static void buildMemberExpressionIterative(Struct sct, List memb newNode.setEL(KeyConstants._callee, callee); } else { - newNode.setEL(KeyConstants._callee, current); + // Method call on object - create MemberExpression for callee + Struct callee = new StructImpl(Struct.TYPE_LINKED); + callee.setEL(KeyConstants._type, "MemberExpression"); + callee.setEL(KeyConstants._computed, false); + callee.setEL(KeyConstants._object, current); + + Struct property = new StructImpl(Struct.TYPE_LINKED); + property.setEL(KeyConstants._type, "Identifier"); + property.setEL(KeyConstants._name, getName((FunctionMember) member)); + callee.setEL(KeyConstants._property, property); + + newNode.setEL(KeyConstants._callee, callee); } - // Add arguments + // Add arguments (use source arguments to exclude evaluator modifications) Array arrArgs = new ArrayImpl(); newNode.setEL(KeyConstants._arguments, arrArgs); FunctionMember fm = (FunctionMember) member; - for (Argument arg: fm.getArguments()) { + for (Argument arg: fm.getSourceArguments()) { Struct sctArg = new StructImpl(Struct.TYPE_LINKED); arrArgs.appendEL(sctArg); arg.dump(sctArg); diff --git a/core/src/main/java/lucee/transformer/cfml/expression/AbstrCFMLExprTransformer.java b/core/src/main/java/lucee/transformer/cfml/expression/AbstrCFMLExprTransformer.java index 11c791c0789..3287639985b 100755 --- a/core/src/main/java/lucee/transformer/cfml/expression/AbstrCFMLExprTransformer.java +++ b/core/src/main/java/lucee/transformer/cfml/expression/AbstrCFMLExprTransformer.java @@ -1361,6 +1361,9 @@ protected Expression json(Data data, FunctionLibFunction flf, char start, char e while (data.srcCode.forwardIfCurrent(',')); comments(data); + // Snapshot original arguments before evaluators may modify them + bif.snapshotSourceArguments(); + if (!data.srcCode.forwardIfCurrent(end)) throw new TemplateException(data.srcCode, "Invalid Syntax Closing [" + end + "] not found"); comments(data); @@ -1860,7 +1863,9 @@ private FunctionMember getFunctionMember(Data data, final ExprString name, boole FunctionLibFunctionArg arg; while (it.hasNext()) { arg = it.next(); - if (arg.getDefaultValue() != null) bif.addArgument(new NamedArgumentImpl(data.factory.createLitString(arg.getName()), + // Skip hidden args (internal metadata like __filename, __mapping) in ast mode only + if (data.ast && arg.isHidden()) continue; + if (arg.getDefaultValue() != null)bif.addArgument(new NamedArgumentImpl(data.factory.createLitString(arg.getName()), data.factory.createLitString(arg.getDefaultValue()), arg.getTypeAsString(), false)); } } @@ -1871,6 +1876,9 @@ private FunctionMember getFunctionMember(Data data, final ExprString name, boole int count = getFunctionMemberAttrs(data, name, checkLibrary, fm, flf); + // Snapshot original arguments before evaluators may modify them + fm.snapshotSourceArguments(); + if (checkLibrary) { // pre if (flf.hasTteClass()) { From c433562ce2c13c44e3924f5ab6cf47f794c0f449 Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Thu, 11 Dec 2025 12:40:05 +0100 Subject: [PATCH 08/71] LDEV-5982 snapshot tag attributes before evaluators in AST mode --- .../bytecode/statement/tag/TagBase.java | 10 ++- .../transformer/cfml/tag/CFMLTransformer.java | 6 ++ test/tickets/LDEV5982.cfc | 76 +++++++++++++++++++ 3 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 test/tickets/LDEV5982.cfc diff --git a/core/src/main/java/lucee/transformer/bytecode/statement/tag/TagBase.java b/core/src/main/java/lucee/transformer/bytecode/statement/tag/TagBase.java index 1a065af647f..816f5a07839 100755 --- a/core/src/main/java/lucee/transformer/bytecode/statement/tag/TagBase.java +++ b/core/src/main/java/lucee/transformer/bytecode/statement/tag/TagBase.java @@ -151,10 +151,18 @@ public Attribute getAttribute(String name) { @Override public Attribute removeAttribute(String name) { // Save original attributes on first removal (for AST dump) + snapshotSourceAttributes(); + return attributes.remove(name); + } + + /** + * Snapshot current attributes for AST dump before evaluators modify them. + * Safe to call multiple times - only first call takes effect. + */ + public void snapshotSourceAttributes() { if (sourceAttributes == null) { sourceAttributes = new LinkedHashMap(attributes); } - return attributes.remove(name); } @Override diff --git a/core/src/main/java/lucee/transformer/cfml/tag/CFMLTransformer.java b/core/src/main/java/lucee/transformer/cfml/tag/CFMLTransformer.java index 97e9ceee53b..2f21f0aefca 100755 --- a/core/src/main/java/lucee/transformer/cfml/tag/CFMLTransformer.java +++ b/core/src/main/java/lucee/transformer/cfml/tag/CFMLTransformer.java @@ -47,6 +47,7 @@ import lucee.transformer.Page; import lucee.transformer.Position; import lucee.transformer.bytecode.statement.StatementBase; +import lucee.transformer.bytecode.statement.tag.TagBase; import lucee.transformer.bytecode.statement.tag.TagFunction; import lucee.transformer.cfml.Data; import lucee.transformer.cfml.ExprTransformer; @@ -706,6 +707,11 @@ private boolean tag(Data data, Body parent) throws TemplateException { // get Attributes attributes(data, tagLibTag, tag); + // Snapshot attributes for AST before evaluators modify them + if (data.ast && tag instanceof TagBase) { + ((TagBase) tag).snapshotSourceAttributes(); + } + if (tagLibTag.hasAttributeEvaluator()) { try { tagLibTag = tagLibTag.getAttributeEvaluator().evaluate(tagLibTag, tag); diff --git a/test/tickets/LDEV5982.cfc b/test/tickets/LDEV5982.cfc new file mode 100644 index 00000000000..f9bcc352ea6 --- /dev/null +++ b/test/tickets/LDEV5982.cfc @@ -0,0 +1,76 @@ +component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { + + function run( testResults, testBox ) { + + describe( "LDEV-5982: cflock internal id attribute leaks into AST", function() { + + it( "cflock should not have internal id attribute in AST", function() { + var code = ''; + var ast = astFromString( code, "tag" ); + + // Find the cflock tag in the AST + var lockTag = findTagByName( ast, "lock" ); + expect( lockTag ).notToBeNull( "cflock tag should be present in AST" ); + + // Get attribute names + var attrs = lockTag.attributes ?: []; + var attrNames = attrs.map( function( a ) { return a.name; } ); + + // Should have type and timeout, but NOT id + expect( attrNames ).toInclude( "type" ); + expect( attrNames ).toInclude( "timeout" ); + expect( attrNames ).notToInclude( "id", "Internal id attribute should not leak into AST" ); + }); + + it( "cflock with explicit name should preserve name in AST", function() { + var code = ''; + var ast = astFromString( code, "tag" ); + + var lockTag = findTagByName( ast, "lock" ); + expect( lockTag ).notToBeNull(); + + var attrs = lockTag.attributes ?: []; + var attrNames = attrs.map( function( a ) { return a.name; } ); + + // Should have name, type, timeout - but NOT id + expect( attrNames ).toInclude( "name" ); + expect( attrNames ).toInclude( "type" ); + expect( attrNames ).toInclude( "timeout" ); + expect( attrNames ).notToInclude( "id", "Internal id attribute should not leak into AST" ); + }); + + }); + } + + /** + * Recursively find a tag by name in the AST + */ + private function findTagByName( required struct node, required string tagName ) { + if ( ( node.type ?: "" ) == "CFMLTag" && ( node.name ?: "" ) == arguments.tagName ) { + return node; + } + if ( structKeyExists( node, "body" ) ) { + if ( isArray( node.body ) ) { + for ( var child in node.body ) { + if ( isStruct( child ) ) { + var result = findTagByName( child, arguments.tagName ); + if ( !isNull( result ) ) return result; + } + } + } else if ( isStruct( node.body ) ) { + var result = findTagByName( node.body, arguments.tagName ); + if ( !isNull( result ) ) return result; + } + } + if ( structKeyExists( node, "children" ) && isArray( node.children ) ) { + for ( var child in node.children ) { + if ( isStruct( child ) ) { + var result = findTagByName( child, arguments.tagName ); + if ( !isNull( result ) ) return result; + } + } + } + return; + } + +} From f47167dcdb4df7c04201a9785e9fb48f9575db11 Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Thu, 11 Dec 2025 14:03:50 +0100 Subject: [PATCH 09/71] LDEV-5983 fix cffunction body duplicating comments between cfargument tags --- .../lucee/transformer/bytecode/BodyBase.java | 4 +- test/tickets/LDEV5983.cfc | 43 +++++++++++++++++++ test/tickets/LDEV5983/commentAfterLastArg.cfm | 4 ++ test/tickets/LDEV5983/commentBetweenArgs.cfm | 5 +++ 4 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 test/tickets/LDEV5983.cfc create mode 100644 test/tickets/LDEV5983/commentAfterLastArg.cfm create mode 100644 test/tickets/LDEV5983/commentBetweenArgs.cfm diff --git a/core/src/main/java/lucee/transformer/bytecode/BodyBase.java b/core/src/main/java/lucee/transformer/bytecode/BodyBase.java index ca50812d66b..84efb8f245f 100755 --- a/core/src/main/java/lucee/transformer/bytecode/BodyBase.java +++ b/core/src/main/java/lucee/transformer/bytecode/BodyBase.java @@ -71,7 +71,9 @@ public void addStatement(Statement statement) { if (statement instanceof PrintOut) { Expression expr = ((PrintOut) statement).getExpr(); - if (expr instanceof LitString && concatPrintouts(((LitString) expr).getString())) return; + // LDEV-5983: Only concatenate if the statement doesn't already belong to another body. + // Otherwise concatenating modifies the original PrintOut which may still be used elsewhere. + if (expr instanceof LitString && statement.getParent() == null && concatPrintouts(((LitString) expr).getString())) return; } statement.setParent(this); this.statements.add(statement); diff --git a/test/tickets/LDEV5983.cfc b/test/tickets/LDEV5983.cfc new file mode 100644 index 00000000000..5eafb95e6b3 --- /dev/null +++ b/test/tickets/LDEV5983.cfc @@ -0,0 +1,43 @@ +component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { + + variables.testDir = getDirectoryFromPath( getCurrentTemplatePath() ) & "LDEV5983/"; + + function run( testResults, testBox ) { + + describe( "LDEV-5983: cffunction body duplicates comments between cfargument tags", function() { + + it( "comments between cfargument tags should not be duplicated in AST", function() { + var code = fileRead( variables.testDir & "commentBetweenArgs.cfm" ); + var ast = astFromString( code, "tag" ); + + var commentCount = countStringInAST( ast, "" ); + expect( commentCount ).toBe( 1, "Comment should appear exactly once in AST, found #commentCount# times" ); + }); + + it( "cffunction with comment after last cfargument should be stable", function() { + var code = fileRead( variables.testDir & "commentAfterLastArg.cfm" ); + var ast = astFromString( code, "tag" ); + + var commentCount = countStringInAST( ast, "" ); + expect( commentCount ).toBe( 1, "Comment should appear exactly once in AST, found #commentCount# times" ); + }); + + }); + + } + + /** + * Count occurrences of a string in StringLiteral values within the AST + */ + private numeric function countStringInAST( required struct ast, required string searchFor ) { + var matches = structFindKey( ast, "value", "all" ); + var count = 0; + for ( var match in matches ) { + if ( isSimpleValue( match.value ) && find( arguments.searchFor, match.value ) ) { + count++; + } + } + return count; + } + +} diff --git a/test/tickets/LDEV5983/commentAfterLastArg.cfm b/test/tickets/LDEV5983/commentAfterLastArg.cfm new file mode 100644 index 00000000000..807f7eea960 --- /dev/null +++ b/test/tickets/LDEV5983/commentAfterLastArg.cfm @@ -0,0 +1,4 @@ + + + + diff --git a/test/tickets/LDEV5983/commentBetweenArgs.cfm b/test/tickets/LDEV5983/commentBetweenArgs.cfm new file mode 100644 index 00000000000..8c00b7a4b9e --- /dev/null +++ b/test/tickets/LDEV5983/commentBetweenArgs.cfm @@ -0,0 +1,5 @@ + + + + + From b88211efa85b51392c08f0591a43400e9433439f Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Thu, 11 Dec 2025 15:07:10 +0100 Subject: [PATCH 10/71] LDEV-5985 fix ClassCastException when parsing Java functions in AST mode --- .../script/AbstrCFMLScriptTransformer.java | 15 ++++- test/tickets/LDEV5985.cfc | 56 +++++++++++++++++++ test/tickets/LDEV5985/javaFunction.cfm | 5 ++ 3 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 test/tickets/LDEV5985.cfc create mode 100644 test/tickets/LDEV5985/javaFunction.cfm diff --git a/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java b/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java index 8b35e90f51a..d43081403e7 100755 --- a/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java +++ b/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java @@ -1258,12 +1258,23 @@ private JavaFunction java(Data data, Body body, String functionName, int access, throw new TemplateException(data.srcCode, e.getMessage()); } - PageSourceCode psc = (PageSourceCode) data.srcCode;// TODO get PS in an other way - PageSource ps = psc.getPageSource(); + // In AST mode (no PageSourceCode), we can't compile Java functions + // but we still need to parse past the function body + PageSource ps = null; + if (data.srcCode instanceof PageSourceCode) { + ps = ((PageSourceCode) data.srcCode).getPageSource(); + } SourceCode sc = data.srcCode; Position start = sc.getPosition(); findTheEnd(data, start.line); + + // In AST mode without PageSource, we can't compile - just return null + // The function body has already been parsed past by findTheEnd + if (ps == null) { + return null; + } + Position end = sc.getPosition(); String javaCode = sc.substring(start.pos, end.pos - start.pos); try { diff --git a/test/tickets/LDEV5985.cfc b/test/tickets/LDEV5985.cfc new file mode 100644 index 00000000000..8ebe9186cca --- /dev/null +++ b/test/tickets/LDEV5985.cfc @@ -0,0 +1,56 @@ +component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { + + variables.testDir = getDirectoryFromPath( getCurrentTemplatePath() ) & "LDEV5985/"; + + function run( testResults, testBox ) { + + describe( "LDEV-5985: Java functions crash AST parser", function() { + + it( "should parse functions with type=java without throwing ClassCastException", function() { + var code = fileRead( variables.testDir & "javaFunction.cfm" ); + + // This currently throws: + // class lucee.transformer.util.SourceCode cannot be cast to class lucee.transformer.util.PageSourceCode + var ast = astFromString( code, "cfml" ); + + expect( ast ).toBeStruct(); + expect( ast.type ).toBe( "Program" ); + }); + + it( "should include java function in AST body", function() { + var code = fileRead( variables.testDir & "javaFunction.cfm" ); + var ast = astFromString( code, "cfml" ); + + // Find the function declaration + var funcDecl = findNodeByType( ast, "FunctionDeclaration" ); + expect( isNull( funcDecl ) ).toBeFalse( "FunctionDeclaration should be present" ); + // Just verify we found a function - the structure may vary + expect( funcDecl.type ).toBe( "FunctionDeclaration" ); + }); + + }); + + } + + private function findNodeByType( required struct node, required string nodeType ) { + if ( ( node.type ?: "" ) == arguments.nodeType ) { + return node; + } + for ( var key in node ) { + var val = node[ key ]; + if ( isStruct( val ) ) { + var result = findNodeByType( val, arguments.nodeType ); + if ( !isNull( result ) ) return result; + } else if ( isArray( val ) ) { + for ( var item in val ) { + if ( isStruct( item ) ) { + var result = findNodeByType( item, arguments.nodeType ); + if ( !isNull( result ) ) return result; + } + } + } + } + return; + } + +} diff --git a/test/tickets/LDEV5985/javaFunction.cfm b/test/tickets/LDEV5985/javaFunction.cfm new file mode 100644 index 00000000000..fe6e20e92b2 --- /dev/null +++ b/test/tickets/LDEV5985/javaFunction.cfm @@ -0,0 +1,5 @@ + +private int function echoInt(int i) type="java" { + return i*2; +} + From d93e94f6fcf2717841909d3835d7357c4fbf71ae Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Thu, 11 Dec 2025 15:14:16 +0100 Subject: [PATCH 11/71] LDEV-5987 fix missing parent statement error with thread tag in AST mode --- .../bytecode/statement/tag/TagThread.java | 9 ++- .../cfml/evaluator/impl/TagThread.java | 11 +-- test/tickets/LDEV5987.cfc | 74 +++++++++++++++++++ test/tickets/LDEV5987/arrowWithThread.cfm | 6 ++ 4 files changed, 87 insertions(+), 13 deletions(-) create mode 100644 test/tickets/LDEV5987.cfc create mode 100644 test/tickets/LDEV5987/arrowWithThread.cfm diff --git a/core/src/main/java/lucee/transformer/bytecode/statement/tag/TagThread.java b/core/src/main/java/lucee/transformer/bytecode/statement/tag/TagThread.java index 3607f9299a3..c8e918f8bd1 100755 --- a/core/src/main/java/lucee/transformer/bytecode/statement/tag/TagThread.java +++ b/core/src/main/java/lucee/transformer/bytecode/statement/tag/TagThread.java @@ -54,13 +54,16 @@ public void outputName() { this.outputName = true; } - public void init() throws TransformerException { + public void init() { String action = ASMUtil.getAttributeString(this, "action", "run"); // no body if (!"run".equalsIgnoreCase(action)) return; - PageImpl page = (PageImpl) ASMUtil.getAncestorPage(null, this); - index = page.addThread(this); + // In AST mode, there may be no Page ancestor (e.g., thread inside arrow function) + PageImpl page = (PageImpl) ASMUtil.getAncestorPage(this, null); + if (page != null) { + index = page.addThread(this); + } } diff --git a/core/src/main/java/lucee/transformer/cfml/evaluator/impl/TagThread.java b/core/src/main/java/lucee/transformer/cfml/evaluator/impl/TagThread.java index 8cce969cc76..47aa23c254f 100644 --- a/core/src/main/java/lucee/transformer/cfml/evaluator/impl/TagThread.java +++ b/core/src/main/java/lucee/transformer/cfml/evaluator/impl/TagThread.java @@ -16,8 +16,6 @@ **/ package lucee.transformer.cfml.evaluator.impl; -import lucee.commons.lang.ExceptionUtil; -import lucee.transformer.TransformerException; import lucee.transformer.cfml.evaluator.EvaluatorException; import lucee.transformer.cfml.evaluator.EvaluatorSupport; import lucee.transformer.library.function.FunctionLib; @@ -29,13 +27,6 @@ public final class TagThread extends EvaluatorSupport { @Override public void evaluate(Tag tag, TagLibTag tagLibTag, FunctionLib flibs) throws EvaluatorException { lucee.transformer.bytecode.statement.tag.TagThread tt = (lucee.transformer.bytecode.statement.tag.TagThread) tag; - try { - tt.init(); - } - catch (TransformerException te) { - EvaluatorException ee = new EvaluatorException(te.getMessage()); - ExceptionUtil.initCauseEL(ee, te); - throw ee; - } + tt.init(); } } \ No newline at end of file diff --git a/test/tickets/LDEV5987.cfc b/test/tickets/LDEV5987.cfc new file mode 100644 index 00000000000..51c5ccaacf4 --- /dev/null +++ b/test/tickets/LDEV5987.cfc @@ -0,0 +1,74 @@ +component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { + + variables.testDir = getDirectoryFromPath( getCurrentTemplatePath() ) & "LDEV5987/"; + + function run( testResults, testBox ) { + + describe( "LDEV-5987: Missing parent statement error with arrow functions and threads", function() { + + it( "should parse arrow function containing thread without error", function() { + var code = fileRead( variables.testDir & "arrowWithThread.cfm" ); + + // This currently throws: missing parent Statement of Statement + var ast = astFromString( code, "cfml" ); + + expect( ast ).toBeStruct(); + expect( ast.type ).toBe( "Program" ); + }); + + it( "should include thread tag in AST", function() { + var code = fileRead( variables.testDir & "arrowWithThread.cfm" ); + var ast = astFromString( code, "cfml" ); + + // Find the thread tag - main thing is it parses without error + var threadTag = findTagByName( ast, "thread" ); + expect( isNull( threadTag ) ).toBeFalse( "Thread tag should be present in AST" ); + }); + + }); + + } + + private function findNodeByType( required struct node, required string nodeType ) { + if ( ( node.type ?: "" ) == arguments.nodeType ) { + return node; + } + for ( var key in node ) { + var val = node[ key ]; + if ( isStruct( val ) ) { + var result = findNodeByType( val, arguments.nodeType ); + if ( !isNull( result ) ) return result; + } else if ( isArray( val ) ) { + for ( var item in val ) { + if ( isStruct( item ) ) { + var result = findNodeByType( item, arguments.nodeType ); + if ( !isNull( result ) ) return result; + } + } + } + } + return; + } + + private function findTagByName( required struct node, required string tagName ) { + if ( ( node.type ?: "" ) == "CFMLTag" && ( node.name ?: "" ) == arguments.tagName ) { + return node; + } + for ( var key in node ) { + var val = node[ key ]; + if ( isStruct( val ) ) { + var result = findTagByName( val, arguments.tagName ); + if ( !isNull( result ) ) return result; + } else if ( isArray( val ) ) { + for ( var item in val ) { + if ( isStruct( item ) ) { + var result = findTagByName( item, arguments.tagName ); + if ( !isNull( result ) ) return result; + } + } + } + } + return; + } + +} diff --git a/test/tickets/LDEV5987/arrowWithThread.cfm b/test/tickets/LDEV5987/arrowWithThread.cfm new file mode 100644 index 00000000000..8a3c175e7e2 --- /dev/null +++ b/test/tickets/LDEV5987/arrowWithThread.cfm @@ -0,0 +1,6 @@ + +runner = () => { + thread name="LDEV5987" {} + return "success"; +} + From 61177a8760da39d7a7335609be9d6907034ccafc Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Thu, 11 Dec 2025 15:23:34 +0100 Subject: [PATCH 12/71] LDEV-5984 escape hashes in StringLiteral raw for AST round-tripping --- .../bytecode/literal/LitStringImpl.java | 4 +- test/tickets/LDEV5984.cfc | 83 +++++++++++++++++++ test/tickets/LDEV5984/escapedHashes.cfm | 3 + 3 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 test/tickets/LDEV5984.cfc create mode 100644 test/tickets/LDEV5984/escapedHashes.cfm diff --git a/core/src/main/java/lucee/transformer/bytecode/literal/LitStringImpl.java b/core/src/main/java/lucee/transformer/bytecode/literal/LitStringImpl.java index a30039e0a7e..3dde6957589 100644 --- a/core/src/main/java/lucee/transformer/bytecode/literal/LitStringImpl.java +++ b/core/src/main/java/lucee/transformer/bytecode/literal/LitStringImpl.java @@ -200,6 +200,8 @@ public void dump(Struct sct) { super.dump(sct); sct.setEL(KeyConstants._type, "StringLiteral"); sct.setEL(KeyConstants._value, str); - sct.setEL(KeyConstants._raw, "\"" + str + "\""); + // Raw must be valid CFML source: escape # to ## and " to "" + String escaped = str.replace("#", "##").replace("\"", "\"\""); + sct.setEL(KeyConstants._raw, "\"" + escaped + "\""); } } \ No newline at end of file diff --git a/test/tickets/LDEV5984.cfc b/test/tickets/LDEV5984.cfc new file mode 100644 index 00000000000..17ac065755b --- /dev/null +++ b/test/tickets/LDEV5984.cfc @@ -0,0 +1,83 @@ +component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { + + variables.testDir = getDirectoryFromPath( getCurrentTemplatePath() ) & "LDEV5984/"; + + function run( testResults, testBox ) { + + describe( "LDEV-5984: Escaped hashes lose escaping in AST", function() { + + it( "raw should contain escaped hashes to allow round-tripping", function() { + var code = fileRead( variables.testDir & "escapedHashes.cfm" ); + var ast = astFromString( code, "cfml" ); + + // Find the StringLiteral in the AST + var stringLiteral = findStringLiteral( ast ); + expect( stringLiteral ).notToBeNull( "StringLiteral should be present in AST" ); + + // The value correctly contains ##hello## (the runtime value) + // But the raw should contain #### so we can regenerate the source + // Currently raw is "##hello##" which loses the escaping info + var rawValue = stringLiteral.raw; + + // Count the hashes - should have 4 before and 4 after "hello" + // i.e. raw should be something like '####hello####' or "####hello####" + var hashCount = len( rawValue ) - len( replace( rawValue, "##", "", "all" ) ); + // Each ## = 2 chars removed, so hashCount / 2 = number of ## sequences + // We expect 4 ## sequences (#### before hello, #### after hello) + expect( hashCount / 2 ).toBeGTE( 4, "Raw should contain #### (escaped ##) not just ## - got: " & rawValue ); + }); + + it( "round-trip should preserve escaped hashes", function() { + // Input has #### which means literal ## in the string value + // Need ######## in test code to produce #### in parsed string + // ######## -> #### (in test) -> ## (in parsed value) = 2 chars per side + var code = "x = '########test########';"; + var ast1 = astFromString( code, "cfml" ); + + var str1 = findStringLiteral( ast1 ); + expect( isNull( str1 ) ).toBeFalse( "First parse should find StringLiteral" ); + + // Value should be ##test## (the runtime value) = 8 chars + var val1 = str1.value; + expect( len( val1 ) ).toBe( 8, "Value should be 8 chars but got len=" & len( val1 ) ); + + // If we use raw to regenerate and reparse, value should be stable + var newCode = "x = " & str1.raw & ";"; + var ast2 = astFromString( newCode, "cfml" ); + var str2 = findStringLiteral( ast2 ); + + expect( isNull( str2 ) ).toBeFalse( "Second parse should find StringLiteral" ); + expect( str2.value ).toBe( val1, "Value should be stable after round-trip" ); + }); + + }); + + } + + /** + * Recursively find a StringLiteral node in the AST that contains hashes + */ + private function findStringLiteral( required struct node ) { + if ( ( node.type ?: "" ) == "StringLiteral" && structKeyExists( node, "value" ) ) { + if ( find( "test", node.value ) || find( "hello", node.value ) ) { + return node; + } + } + for ( var key in node ) { + var val = node[ key ]; + if ( isStruct( val ) ) { + var result = findStringLiteral( val ); + if ( !isNull( result ) ) return result; + } else if ( isArray( val ) ) { + for ( var item in val ) { + if ( isStruct( item ) ) { + var result = findStringLiteral( item ); + if ( !isNull( result ) ) return result; + } + } + } + } + return; + } + +} diff --git a/test/tickets/LDEV5984/escapedHashes.cfm b/test/tickets/LDEV5984/escapedHashes.cfm new file mode 100644 index 00000000000..2028683e321 --- /dev/null +++ b/test/tickets/LDEV5984/escapedHashes.cfm @@ -0,0 +1,3 @@ + +x = '####hello####'; + From 16e055d8f2555deea6688aa8b22e4242713d634b Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Thu, 11 Dec 2025 15:34:48 +0100 Subject: [PATCH 13/71] LDEV-5986 add TagIsland node type for tag islands in AST --- .../bytecode/statement/TagIsland.java | 102 ++++++++++++++++++ .../script/AbstrCFMLScriptTransformer.java | 15 ++- test/tickets/LDEV5986.cfc | 95 ++++++++++++++++ test/tickets/LDEV5986/tagIsland.cfm | 7 ++ 4 files changed, 217 insertions(+), 2 deletions(-) create mode 100644 core/src/main/java/lucee/transformer/bytecode/statement/TagIsland.java create mode 100644 test/tickets/LDEV5986.cfc create mode 100644 test/tickets/LDEV5986/tagIsland.cfm diff --git a/core/src/main/java/lucee/transformer/bytecode/statement/TagIsland.java b/core/src/main/java/lucee/transformer/bytecode/statement/TagIsland.java new file mode 100644 index 00000000000..7f80781e24e --- /dev/null +++ b/core/src/main/java/lucee/transformer/bytecode/statement/TagIsland.java @@ -0,0 +1,102 @@ +package lucee.transformer.bytecode.statement; + +import java.util.List; + +import lucee.runtime.type.Array; +import lucee.runtime.type.ArrayImpl; +import lucee.runtime.type.Struct; +import lucee.runtime.type.StructImpl; +import lucee.runtime.type.util.KeyConstants; +import lucee.transformer.Body; +import lucee.transformer.Factory; +import lucee.transformer.Position; +import lucee.transformer.TransformerException; +import lucee.transformer.bytecode.BodyBase; +import lucee.transformer.bytecode.BytecodeContext; +import lucee.transformer.statement.Statement; + +/** + * Represents a tag island in cfscript - tag code embedded between ``` delimiters + */ +public final class TagIsland extends StatementBaseNoFinal implements Body { + + private Body body; + + public TagIsland(Factory f, Position start, Position end) { + super(f, start, end); + this.body = new BodyBase(f); + } + + public Body getBody() { + return body; + } + + @Override + public void _writeOut(BytecodeContext bc) throws TransformerException { + // Write out the body contents + body.writeOut(bc); + } + + @Override + public void dump(Struct sct) { + super.dump(sct); + sct.setEL(KeyConstants._type, "TagIsland"); + + // Dump body as array of statements + Array bodyArr = new ArrayImpl(); + List statements = body.getStatements(); + for (Statement stmt : statements) { + Struct stmtSct = new StructImpl(Struct.TYPE_LINKED); + stmt.dump(stmtSct); + bodyArr.appendEL(stmtSct); + } + sct.setEL("body", bodyArr); + } + + // Body interface methods - delegate to inner body + @Override + public void addStatement(Statement statement) { + body.addStatement(statement); + } + + @Override + public void addFirst(Statement statement) { + body.addFirst(statement); + } + + @Override + public List getStatements() { + return body.getStatements(); + } + + @Override + public boolean hasStatements() { + return body.hasStatements(); + } + + @Override + public boolean isEmpty() { + return body.isEmpty(); + } + + @Override + public void setParent(Statement parent) { + super.setParent(parent); + body.setParent(parent); + } + + @Override + public void addPrintOut(Factory factory, String str, Position start, Position end) { + body.addPrintOut(factory, str, start, end); + } + + @Override + public void moveStatmentsTo(Body trg) { + body.moveStatmentsTo(trg); + } + + @Override + public void remove(Statement stat) { + body.remove(stat); + } +} diff --git a/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java b/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java index d43081403e7..f5322609b81 100755 --- a/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java +++ b/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java @@ -68,6 +68,7 @@ import lucee.transformer.bytecode.statement.ForEach; import lucee.transformer.bytecode.statement.Return; import lucee.transformer.bytecode.statement.Switch; +import lucee.transformer.bytecode.statement.TagIsland; import lucee.transformer.bytecode.statement.TryCatchFinally; import lucee.transformer.bytecode.statement.While; import lucee.transformer.bytecode.statement.tag.TagComponent; @@ -2030,11 +2031,21 @@ private final Return returnStatement(Data data) throws TemplateException { private final boolean islandStatement(Data data, Body parent) throws TemplateException { if (!data.srcCode.forwardIfCurrent(TAG_ISLAND_INDICATOR)) return false; - // now we have to jump into the tag parser + + Position start = data.srcCode.getPosition(); + + // Create a TagIsland wrapper to hold the tag content + TagIsland island = new TagIsland(data.factory, start, null); + + // now we have to jump into the tag parser, parsing into the TagIsland body CFMLTransformer tag = new CFMLTransformer(true); - tag.transform(data, parent); + tag.transform(data, island); if (!data.srcCode.forwardIfCurrent(TAG_ISLAND_INDICATOR)) throw new TemplateException(data.srcCode, "missing closing tag indicator [" + TAG_ISLAND_INDICATOR + "]"); + + island.setEnd(data.srcCode.getPosition()); + parent.addStatement(island); + comments(data); return true; diff --git a/test/tickets/LDEV5986.cfc b/test/tickets/LDEV5986.cfc new file mode 100644 index 00000000000..7e9a55a174e --- /dev/null +++ b/test/tickets/LDEV5986.cfc @@ -0,0 +1,95 @@ +component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { + + variables.testDir = getDirectoryFromPath( getCurrentTemplatePath() ) & "LDEV5986/"; + + function run( testResults, testBox ) { + + describe( "LDEV-5986: Tag islands should have dedicated node type in AST", function() { + + it( "should have a TagIsland node type wrapping the tag content", function() { + var code = fileRead( variables.testDir & "tagIsland.cfm" ); + var ast = astFromString( code, "cfml" ); + + // Tag islands should have their own node type like TagIsland or similar + // Currently the backticks become StringLiteral with "\r\n" and the + // tag content is parsed inline without being wrapped in a TagIsland node + + // Check for a TagIsland node (or similar dedicated type) + var tagIsland = findNodeByType( ast, "TagIsland" ); + expect( isNull( tagIsland ) ).toBeFalse( "Tag island should have a dedicated TagIsland node type" ); + }); + + it( "tag island should not produce spurious StringLiteral from backticks", function() { + var code = fileRead( variables.testDir & "tagIsland.cfm" ); + var ast = astFromString( code, "cfml" ); + + // Currently the backtick delimiters ``` become StringLiteral nodes + // with value "\r\n" - these shouldn't exist + var scriptBody = findScriptBody( ast ); + expect( scriptBody ).notToBeNull( "Should find cfscript body" ); + + // Count StringLiterals that are just whitespace - these come from the backticks + var spuriousStrings = 0; + for ( var stmt in scriptBody ) { + if ( ( stmt.type ?: "" ) == "ExpressionStatement" ) { + var expr = stmt.expression ?: {}; + if ( ( expr.type ?: "" ) == "StringLiteral" ) { + var val = expr.value ?: ""; + if ( reFind( "^[\r\n\s]*$", val ) ) { + spuriousStrings++; + } + } + } + } + expect( spuriousStrings ).toBe( 0, "Backtick delimiters should not become StringLiteral nodes" ); + }); + + }); + + } + + private function findNodeByType( required struct node, required string nodeType ) { + if ( ( node.type ?: "" ) == arguments.nodeType ) { + return node; + } + for ( var key in node ) { + var val = node[ key ]; + if ( isStruct( val ) ) { + var result = findNodeByType( val, arguments.nodeType ); + if ( !isNull( result ) ) return result; + } else if ( isArray( val ) ) { + for ( var item in val ) { + if ( isStruct( item ) ) { + var result = findNodeByType( item, arguments.nodeType ); + if ( !isNull( result ) ) return result; + } + } + } + } + return; + } + + private function findScriptBody( required struct node ) { + if ( ( node.type ?: "" ) == "CFMLTag" && ( node.name ?: "" ) == "script" ) { + if ( structKeyExists( node, "body" ) && structKeyExists( node.body, "body" ) ) { + return node.body.body; + } + } + for ( var key in node ) { + var val = node[ key ]; + if ( isStruct( val ) ) { + var result = findScriptBody( val ); + if ( !isNull( result ) ) return result; + } else if ( isArray( val ) ) { + for ( var item in val ) { + if ( isStruct( item ) ) { + var result = findScriptBody( item ); + if ( !isNull( result ) ) return result; + } + } + } + } + return; + } + +} diff --git a/test/tickets/LDEV5986/tagIsland.cfm b/test/tickets/LDEV5986/tagIsland.cfm new file mode 100644 index 00000000000..2f7a89d42df --- /dev/null +++ b/test/tickets/LDEV5986/tagIsland.cfm @@ -0,0 +1,7 @@ + +x = 1; +``` + +``` +z = 3; + From 2861320d6886fe9561622069699ea76e80b2b7c4 Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Thu, 11 Dec 2025 15:51:28 +0100 Subject: [PATCH 14/71] LDEV-5988 handle null string in LitStringImpl.dump() for AST mode --- .../transformer/bytecode/literal/LitStringImpl.java | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/core/src/main/java/lucee/transformer/bytecode/literal/LitStringImpl.java b/core/src/main/java/lucee/transformer/bytecode/literal/LitStringImpl.java index 3dde6957589..acc63397d9f 100644 --- a/core/src/main/java/lucee/transformer/bytecode/literal/LitStringImpl.java +++ b/core/src/main/java/lucee/transformer/bytecode/literal/LitStringImpl.java @@ -201,7 +201,12 @@ public void dump(Struct sct) { sct.setEL(KeyConstants._type, "StringLiteral"); sct.setEL(KeyConstants._value, str); // Raw must be valid CFML source: escape # to ## and " to "" - String escaped = str.replace("#", "##").replace("\"", "\"\""); - sct.setEL(KeyConstants._raw, "\"" + escaped + "\""); + if (str != null) { + String escaped = str.replace("#", "##").replace("\"", "\"\""); + sct.setEL(KeyConstants._raw, "\"" + escaped + "\""); + } + else { + sct.setEL(KeyConstants._raw, "null"); + } } } \ No newline at end of file From a0e29706e8f459aed417b99d2da2b9562c16c3a7 Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Thu, 11 Dec 2025 16:19:41 +0100 Subject: [PATCH 15/71] LDEV-5715 validate mode parameter in astFromString() Throws FunctionException for invalid mode values instead of silently defaulting to tag mode. --- .../runtime/functions/ast/AstFromString.java | 9 ++++++--- test/functions/astFromString.cfc | 18 ++++++++++++++++++ test/tickets/LDEV5975.cfc | 4 ++-- test/tickets/LDEV5977.cfc | 2 +- test/tickets/LDEV5978.cfc | 2 +- test/tickets/LDEV5979.cfc | 2 +- test/tickets/LDEV5984.cfc | 6 +++--- test/tickets/LDEV5985.cfc | 4 ++-- test/tickets/LDEV5986.cfc | 4 ++-- test/tickets/LDEV5987.cfc | 4 ++-- 10 files changed, 38 insertions(+), 17 deletions(-) diff --git a/core/src/main/java/lucee/runtime/functions/ast/AstFromString.java b/core/src/main/java/lucee/runtime/functions/ast/AstFromString.java index 4a953924f83..1a496d9f517 100644 --- a/core/src/main/java/lucee/runtime/functions/ast/AstFromString.java +++ b/core/src/main/java/lucee/runtime/functions/ast/AstFromString.java @@ -23,9 +23,12 @@ public Object invoke(PageContext pc, Object[] args) throws PageException { Boolean script = Boolean.FALSE; if (args.length == 2) { String mode = Caster.toString(args[1], null); - // - if ("script".equalsIgnoreCase(mode)) script = Boolean.TRUE; - + if ("script".equalsIgnoreCase(mode)) { + script = Boolean.TRUE; + } + else if (mode != null && !"tag".equalsIgnoreCase(mode)) { + throw new FunctionException(pc, "AstFromString", 2, "mode", "invalid mode [" + mode + "], valid values are [tag, script]"); + } } return ((PageContextImpl) pc).transform(new SourceCode(null, content, false, 0), script); diff --git a/test/functions/astFromString.cfc b/test/functions/astFromString.cfc index 0c1e1387cc6..9f01e2c7869 100644 --- a/test/functions/astFromString.cfc +++ b/test/functions/astFromString.cfc @@ -146,6 +146,24 @@ component extends = "org.lucee.cfml.test.LuceeTestCase" { assertEquals("loop", result.body[1].name); }); + it( title = 'test mode=tag works explicitly', body = function( currentSpec ) { + var result = astFromString("", "tag"); + assertEquals("Program", result.type); + assertTrue(arrayLen(result.body) > 0); + }); + + it( title = 'test invalid mode throws error', body = function( currentSpec ) { + var errorThrown = false; + try { + astFromString("x = 1;", "banana"); + } + catch( any e ) { + errorThrown = true; + assertTrue( e.message contains "mode" || e.detail contains "mode", "Error should mention 'mode'" ); + } + assertTrue( errorThrown, "Expected an error for invalid mode 'banana'" ); + }); + xit( title = 'test unknown script tag in legacy style', body = function( currentSpec ) { var result = astFromString('unknown susi="1" {echo("ddd");}',"script"); assertEquals("Program", result.type); diff --git a/test/tickets/LDEV5975.cfc b/test/tickets/LDEV5975.cfc index 38fcbfb44df..365e4c5c6e9 100644 --- a/test/tickets/LDEV5975.cfc +++ b/test/tickets/LDEV5975.cfc @@ -8,7 +8,7 @@ component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { it( "simple method call should include method name in AST", function() { var code = fileRead( variables.testDir & "simpleMethodCall.cfm" ); - var ast = astFromString( code, "cfml" ); + var ast = astFromString( code ); // Find the CallExpression var callExpr = findNodeByType( ast, "CallExpression" ); @@ -41,7 +41,7 @@ component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { it( "chained method calls should preserve all method names", function() { var code = fileRead( variables.testDir & "chainedCalls.cfm" ); - var ast = astFromString( code, "cfml" ); + var ast = astFromString( code ); // Find all method names in the AST var methodNames = findAllMethodNames( ast ); diff --git a/test/tickets/LDEV5977.cfc b/test/tickets/LDEV5977.cfc index f293e6b7e57..9e01937c94d 100644 --- a/test/tickets/LDEV5977.cfc +++ b/test/tickets/LDEV5977.cfc @@ -8,7 +8,7 @@ component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { it( "ternary alternate should be different from consequent", function() { var code = fileRead( variables.testDir & "ternary.cfm" ); - var ast = astFromString( code, "cfml" ); + var ast = astFromString( code ); // Find the ConditionalExpression var condExpr = findNodeByType( ast, "ConditionalExpression" ); diff --git a/test/tickets/LDEV5978.cfc b/test/tickets/LDEV5978.cfc index 4b3cdbb916e..9ba36fd30bf 100644 --- a/test/tickets/LDEV5978.cfc +++ b/test/tickets/LDEV5978.cfc @@ -8,7 +8,7 @@ component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { it( "queryExecute with 1 argument should have 1 argument in AST", function() { var code = fileRead( variables.testDir & "queryExecute.cfm" ); - var ast = astFromString( code, "cfml" ); + var ast = astFromString( code ); // Find the CallExpression for queryExecute var callExpr = findCallByName( ast, "QUERYEXECUTE" ); diff --git a/test/tickets/LDEV5979.cfc b/test/tickets/LDEV5979.cfc index ea63c3dab6a..d8f11b8cffe 100644 --- a/test/tickets/LDEV5979.cfc +++ b/test/tickets/LDEV5979.cfc @@ -8,7 +8,7 @@ component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { it( "isDefined with 1 argument should have 1 argument in AST", function() { var code = fileRead( variables.testDir & "isDefined.cfm" ); - var ast = astFromString( code, "cfml" ); + var ast = astFromString( code ); // Find the CallExpression for isDefined var callExpr = findCallByName( ast, "ISDEFINED" ); diff --git a/test/tickets/LDEV5984.cfc b/test/tickets/LDEV5984.cfc index 17ac065755b..88eaa9a3399 100644 --- a/test/tickets/LDEV5984.cfc +++ b/test/tickets/LDEV5984.cfc @@ -8,7 +8,7 @@ component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { it( "raw should contain escaped hashes to allow round-tripping", function() { var code = fileRead( variables.testDir & "escapedHashes.cfm" ); - var ast = astFromString( code, "cfml" ); + var ast = astFromString( code ); // Find the StringLiteral in the AST var stringLiteral = findStringLiteral( ast ); @@ -32,7 +32,7 @@ component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { // Need ######## in test code to produce #### in parsed string // ######## -> #### (in test) -> ## (in parsed value) = 2 chars per side var code = "x = '########test########';"; - var ast1 = astFromString( code, "cfml" ); + var ast1 = astFromString( code ); var str1 = findStringLiteral( ast1 ); expect( isNull( str1 ) ).toBeFalse( "First parse should find StringLiteral" ); @@ -43,7 +43,7 @@ component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { // If we use raw to regenerate and reparse, value should be stable var newCode = "x = " & str1.raw & ";"; - var ast2 = astFromString( newCode, "cfml" ); + var ast2 = astFromString( newCode ); var str2 = findStringLiteral( ast2 ); expect( isNull( str2 ) ).toBeFalse( "Second parse should find StringLiteral" ); diff --git a/test/tickets/LDEV5985.cfc b/test/tickets/LDEV5985.cfc index 8ebe9186cca..af3a8749ab2 100644 --- a/test/tickets/LDEV5985.cfc +++ b/test/tickets/LDEV5985.cfc @@ -11,7 +11,7 @@ component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { // This currently throws: // class lucee.transformer.util.SourceCode cannot be cast to class lucee.transformer.util.PageSourceCode - var ast = astFromString( code, "cfml" ); + var ast = astFromString( code ); expect( ast ).toBeStruct(); expect( ast.type ).toBe( "Program" ); @@ -19,7 +19,7 @@ component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { it( "should include java function in AST body", function() { var code = fileRead( variables.testDir & "javaFunction.cfm" ); - var ast = astFromString( code, "cfml" ); + var ast = astFromString( code ); // Find the function declaration var funcDecl = findNodeByType( ast, "FunctionDeclaration" ); diff --git a/test/tickets/LDEV5986.cfc b/test/tickets/LDEV5986.cfc index 7e9a55a174e..8b7cdf957a6 100644 --- a/test/tickets/LDEV5986.cfc +++ b/test/tickets/LDEV5986.cfc @@ -8,7 +8,7 @@ component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { it( "should have a TagIsland node type wrapping the tag content", function() { var code = fileRead( variables.testDir & "tagIsland.cfm" ); - var ast = astFromString( code, "cfml" ); + var ast = astFromString( code ); // Tag islands should have their own node type like TagIsland or similar // Currently the backticks become StringLiteral with "\r\n" and the @@ -21,7 +21,7 @@ component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { it( "tag island should not produce spurious StringLiteral from backticks", function() { var code = fileRead( variables.testDir & "tagIsland.cfm" ); - var ast = astFromString( code, "cfml" ); + var ast = astFromString( code ); // Currently the backtick delimiters ``` become StringLiteral nodes // with value "\r\n" - these shouldn't exist diff --git a/test/tickets/LDEV5987.cfc b/test/tickets/LDEV5987.cfc index 51c5ccaacf4..dc7650e9a43 100644 --- a/test/tickets/LDEV5987.cfc +++ b/test/tickets/LDEV5987.cfc @@ -10,7 +10,7 @@ component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { var code = fileRead( variables.testDir & "arrowWithThread.cfm" ); // This currently throws: missing parent Statement of Statement - var ast = astFromString( code, "cfml" ); + var ast = astFromString( code ); expect( ast ).toBeStruct(); expect( ast.type ).toBe( "Program" ); @@ -18,7 +18,7 @@ component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { it( "should include thread tag in AST", function() { var code = fileRead( variables.testDir & "arrowWithThread.cfm" ); - var ast = astFromString( code, "cfml" ); + var ast = astFromString( code ); // Find the thread tag - main thing is it parses without error var threadTag = findTagByName( ast, "thread" ); From db725e2d7ecab33c6259f12829dc1bce4cc5e0f0 Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Thu, 11 Dec 2025 17:10:18 +0100 Subject: [PATCH 16/71] LDEV-5989 preserve original source in AST raw field for SimpleExprTransformer --- .../bytecode/literal/LitStringImpl.java | 24 +++++- .../expression/SimpleExprTransformer.java | 15 +++- test/tickets/LDEV5989.cfc | 81 +++++++++++++++++++ test/tickets/LDEV5989/interpolatedAttr.cfm | 1 + test/tickets/LDEV5989/loopTemplate.txt | 1 + test/tickets/LDEV5989/roundtrip1.cfm | 1 + test/tickets/LDEV5989/roundtrip2.cfm | 1 + 7 files changed, 120 insertions(+), 4 deletions(-) create mode 100644 test/tickets/LDEV5989.cfc create mode 100644 test/tickets/LDEV5989/interpolatedAttr.cfm create mode 100644 test/tickets/LDEV5989/loopTemplate.txt create mode 100644 test/tickets/LDEV5989/roundtrip1.cfm create mode 100644 test/tickets/LDEV5989/roundtrip2.cfm diff --git a/core/src/main/java/lucee/transformer/bytecode/literal/LitStringImpl.java b/core/src/main/java/lucee/transformer/bytecode/literal/LitStringImpl.java index acc63397d9f..c800bc15faa 100644 --- a/core/src/main/java/lucee/transformer/bytecode/literal/LitStringImpl.java +++ b/core/src/main/java/lucee/transformer/bytecode/literal/LitStringImpl.java @@ -50,6 +50,7 @@ public class LitStringImpl extends ExpressionBase implements LitString, ExprStri public static final int TYPE_LOWER = 2; private String str; + private String rawSource; // Original source representation for AST dump private boolean fromBracket; /* @@ -195,13 +196,32 @@ public boolean fromBracket() { return fromBracket; } + /** + * Set the original source representation for AST dump. + * This preserves the exact source text including quotes. + */ + public void setRawSource(String rawSource) { + this.rawSource = rawSource; + } + + /** + * Get the original source representation. + */ + public String getRawSource() { + return rawSource; + } + @Override public void dump(Struct sct) { super.dump(sct); sct.setEL(KeyConstants._type, "StringLiteral"); sct.setEL(KeyConstants._value, str); - // Raw must be valid CFML source: escape # to ## and " to "" - if (str != null) { + // Use rawSource if available (preserves original source representation) + // Otherwise compute from value: escape # to ## and " to "" + if (rawSource != null) { + sct.setEL(KeyConstants._raw, rawSource); + } + else if (str != null) { String escaped = str.replace("#", "##").replace("\"", "\"\""); sct.setEL(KeyConstants._raw, "\"" + escaped + "\""); } diff --git a/core/src/main/java/lucee/transformer/cfml/expression/SimpleExprTransformer.java b/core/src/main/java/lucee/transformer/cfml/expression/SimpleExprTransformer.java index 1769f8d389a..31252af3d2c 100755 --- a/core/src/main/java/lucee/transformer/cfml/expression/SimpleExprTransformer.java +++ b/core/src/main/java/lucee/transformer/cfml/expression/SimpleExprTransformer.java @@ -24,6 +24,7 @@ import lucee.transformer.cfml.Data; import lucee.transformer.cfml.ExprTransformer; import lucee.transformer.expression.Expression; +import lucee.transformer.bytecode.literal.LitStringImpl; import lucee.transformer.expression.literal.LitString; import lucee.transformer.util.SourceCode; @@ -56,7 +57,7 @@ public Expression transform(Data data) throws TemplateException { /** * Liest den String ein - * + * * @return Element * @throws TemplateException */ @@ -65,6 +66,8 @@ public Expression string(Factory f, SourceCode cfml) throws TemplateException { char quoter = cfml.getCurrentLower(); if (quoter != '"' && quoter != '\'') return null; StringBuilder str = new StringBuilder(); + StringBuilder rawSource = new StringBuilder(); + rawSource.append(quoter); // Start with opening quote boolean insideSpecial = false; Position line = cfml.getPosition(); @@ -74,7 +77,7 @@ public Expression string(Factory f, SourceCode cfml) throws TemplateException { if (cfml.isCurrent(specialChar)) { insideSpecial = !insideSpecial; str.append(specialChar); - + rawSource.append(specialChar); } // check quoter else if (!insideSpecial && cfml.isCurrent(quoter)) { @@ -82,6 +85,8 @@ else if (!insideSpecial && cfml.isCurrent(quoter)) { if (cfml.isNext(quoter)) { cfml.next(); str.append(quoter); + rawSource.append(quoter); + rawSource.append(quoter); // escaped quote in raw } // finish else { @@ -91,12 +96,18 @@ else if (!insideSpecial && cfml.isCurrent(quoter)) { // all other character else { str.append(cfml.getCurrent()); + rawSource.append(cfml.getCurrent()); } } if (!cfml.forwardIfCurrent(quoter)) throw new TemplateException(cfml, "Invalid Syntax Closing [" + quoter + "] not found"); + rawSource.append(quoter); // End with closing quote LitString rtn = f.createLitString(str.toString(), line, cfml.getPosition()); + // Set raw source to preserve original representation for AST dump + if (rtn instanceof LitStringImpl) { + ((LitStringImpl) rtn).setRawSource(rawSource.toString()); + } cfml.removeSpace(); return rtn; } diff --git a/test/tickets/LDEV5989.cfc b/test/tickets/LDEV5989.cfc new file mode 100644 index 00000000000..cc8559e1fff --- /dev/null +++ b/test/tickets/LDEV5989.cfc @@ -0,0 +1,81 @@ +component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { + + variables.testDir = getDirectoryFromPath( getCurrentTemplatePath() ) & "LDEV5989/"; + + function run( testResults, testBox ) { + + describe( "LDEV-5989: Interpolated attribute values have escaped hashes in raw field", function() { + + it( "raw should contain original single hashes for interpolated attributes", function() { + var code = fileRead( variables.testDir & "interpolatedAttr.cfm" ); + var ast = astFromString( code ); + + // Find the condition attribute + var attr = findAttribute( ast, "condition" ); + expect( attr ).notToBeNull( "condition attribute should be present in AST" ); + + // The raw field should contain single hashes like the original source + // NOT escaped hashes like "##it.hasNext()##" + var rawValue = attr.value.raw; + + // Check for doubled hashes (##) in raw - use chr(35) to avoid CFML escaping issues + // If raw has ##, find() will locate chr(35)&chr(35) sequence + var doubleHash = chr( 35 ) & chr( 35 ); + var hasDoubleHash = find( doubleHash, rawValue ) > 0; + + // If hasDoubleHash is true, raw contains ## which is the bug + expect( hasDoubleHash ).toBeFalse( "Raw should contain single hashes, not escaped ## - got: " & rawValue ); + }); + + it( "round-trip should preserve interpolated attribute values", function() { + // Parse file with interpolated attribute + var code1 = fileRead( variables.testDir & "roundtrip1.cfm" ); + var ast1 = astFromString( code1 ); + + var attr1 = findAttribute( ast1, "condition" ); + expect( attr1 ).notToBeNull( "First parse should find condition attribute" ); + var raw1 = attr1.value.raw; + + // Build round-trip file using template + var template = fileRead( variables.testDir & "loopTemplate.txt" ); + var code2 = replace( template, "%%CONDITION%%", raw1 ); + fileWrite( variables.testDir & "roundtrip2.cfm", code2 ); + + // Parse the round-trip file + var ast2 = astFromString( code2 ); + var attr2 = findAttribute( ast2, "condition" ); + expect( attr2 ).notToBeNull( "Second parse should find condition attribute" ); + + // Raw should be stable - not doubling hashes each round + expect( attr2.value.raw ).toBe( raw1, "Raw should be stable after round-trip, not doubling hashes" ); + }); + + }); + + } + + /** + * Recursively find an Attribute node by name + */ + private function findAttribute( required struct node, required string name ) { + if ( ( node.type ?: "" ) == "Attribute" && ( node.name ?: "" ) == name ) { + return node; + } + for ( var key in node ) { + var val = node[ key ]; + if ( isStruct( val ) ) { + var result = findAttribute( val, name ); + if ( !isNull( result ) ) return result; + } else if ( isArray( val ) ) { + for ( var item in val ) { + if ( isStruct( item ) ) { + var result = findAttribute( item, name ); + if ( !isNull( result ) ) return result; + } + } + } + } + return; + } + +} diff --git a/test/tickets/LDEV5989/interpolatedAttr.cfm b/test/tickets/LDEV5989/interpolatedAttr.cfm new file mode 100644 index 00000000000..39e5de31a2e --- /dev/null +++ b/test/tickets/LDEV5989/interpolatedAttr.cfm @@ -0,0 +1 @@ + diff --git a/test/tickets/LDEV5989/loopTemplate.txt b/test/tickets/LDEV5989/loopTemplate.txt new file mode 100644 index 00000000000..232c39dbf0f --- /dev/null +++ b/test/tickets/LDEV5989/loopTemplate.txt @@ -0,0 +1 @@ + diff --git a/test/tickets/LDEV5989/roundtrip1.cfm b/test/tickets/LDEV5989/roundtrip1.cfm new file mode 100644 index 00000000000..9d945f9c852 --- /dev/null +++ b/test/tickets/LDEV5989/roundtrip1.cfm @@ -0,0 +1 @@ + diff --git a/test/tickets/LDEV5989/roundtrip2.cfm b/test/tickets/LDEV5989/roundtrip2.cfm new file mode 100644 index 00000000000..9d945f9c852 --- /dev/null +++ b/test/tickets/LDEV5989/roundtrip2.cfm @@ -0,0 +1 @@ + From bf982cba26c01c720e8bb6db2acb2e4a63f9fd1e Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Thu, 11 Dec 2025 19:14:39 +0100 Subject: [PATCH 17/71] LDEV-5990: Function param hint and defaultValue missing from AST --- .../script/AbstrCFMLScriptTransformer.java | 19 +++- test/tickets/LDEV5990.cfc | 89 +++++++++++++++++++ test/tickets/LDEV5990/hintedParams.cfc | 21 +++++ 3 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 test/tickets/LDEV5990.cfc create mode 100644 test/tickets/LDEV5990/hintedParams.cfc diff --git a/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java b/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java index f5322609b81..2bad97c1a18 100755 --- a/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java +++ b/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java @@ -1039,7 +1039,24 @@ else if (idName.indexOf('.') != -1 || idName.indexOf('[') != -1) { if (meta == null) meta = new HashMap(); for (int i = 0; i < _attrs.length; i++) { _attr = _attrs[i]; - meta.put(_attr.getName(), _attr); + String attrName = _attr.getName(); + // Extract known argument attributes + if ("hint".equalsIgnoreCase(attrName)) { + hint = data.factory.toExprString(_attr.getValue()); + } + else if ("displayname".equalsIgnoreCase(attrName)) { + displayName = data.factory.toExprString(_attr.getValue()); + } + else if ("default".equalsIgnoreCase(attrName) && defVal == null) { + defVal = _attr.getValue(); + } + else if ("passbyreference".equalsIgnoreCase(attrName) || "passby".equalsIgnoreCase(attrName)) { + ExprBoolean eb = data.factory.toExprBoolean(_attr.getValue()); + if (eb instanceof LitBoolean) passByRef = (LitBoolean) eb; + } + else { + meta.put(attrName, _attr); + } } } diff --git a/test/tickets/LDEV5990.cfc b/test/tickets/LDEV5990.cfc new file mode 100644 index 00000000000..d5c0f053652 --- /dev/null +++ b/test/tickets/LDEV5990.cfc @@ -0,0 +1,89 @@ +component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { + + variables.testDir = getDirectoryFromPath( getCurrentTemplatePath() ) & "LDEV5990/"; + + function run( testResults, testBox ) { + + describe( "LDEV-5990: Function param hint and defaultValue missing from AST", function() { + + it( "should include hint value on function parameters", function() { + // Use astFromPath for CFC files + var ast = astFromPath( variables.testDir & "hintedParams.cfc" ); + + // Find the withHint function + var func = findFunction( ast, "withHint" ); + expect( func ).notToBeNull( "withHint function should be found in AST" ); + + // Check the first param has hint with correct value + var param = func.params[ 1 ]; + expect( param.name.value ).toBe( "name" ); + expect( param.hint ).notToBeNull( "param should have hint attribute" ); + // Bug: hint.value is empty string instead of the actual hint text + expect( param.hint.value ).toBe( "The user's full name", "hint value should contain the original hint text" ); + }); + + it( "should include defaultValue on function parameters", function() { + var ast = astFromPath( variables.testDir & "hintedParams.cfc" ); + + // Find the withDefault function + var func = findFunction( ast, "withDefault" ); + expect( func ).notToBeNull( "withDefault function should be found in AST" ); + + // Check the first param has defaultValue + var param = func.params[ 1 ]; + expect( param.name.value ).toBe( "greeting" ); + // Bug: defaultValue key is completely missing from param + expect( structKeyExists( param, "defaultValue" ) ).toBeTrue( "param should have defaultValue key" ); + expect( param.defaultValue.value ).toBe( "Hello", "defaultValue should contain the default value" ); + }); + + it( "should include both hint and defaultValue on same parameter", function() { + var ast = astFromPath( variables.testDir & "hintedParams.cfc" ); + + // Find the withBoth function + var func = findFunction( ast, "withBoth" ); + expect( func ).notToBeNull( "withBoth function should be found in AST" ); + + // Check the first param has both hint and defaultValue + var param = func.params[ 1 ]; + expect( param.name.value ).toBe( "message" ); + // Bug: hint.value is empty, defaultValue key is missing + expect( param.hint.value ).toBe( "The message to display", "hint value should be preserved" ); + expect( structKeyExists( param, "defaultValue" ) ).toBeTrue( "param should have defaultValue key" ); + expect( param.defaultValue.value ).toBe( "Welcome", "defaultValue should be preserved" ); + }); + + }); + + } + + /** + * Recursively find a FunctionDeclaration by name + */ + private function findFunction( required struct node, required string name ) { + var nodeType = node.type ?: ""; + if ( isSimpleValue( nodeType ) && nodeType == "FunctionDeclaration" ) { + var nodeName = node.name ?: ""; + if ( isStruct( nodeName ) ) nodeName = nodeName.value ?: ""; + if ( isSimpleValue( nodeName ) && uCase( nodeName ) == uCase( name ) ) { + return node; + } + } + for ( var key in node ) { + var val = node[ key ]; + if ( isStruct( val ) ) { + var result = findFunction( val, name ); + if ( !isNull( result ) ) return result; + } else if ( isArray( val ) ) { + for ( var item in val ) { + if ( isStruct( item ) ) { + var result = findFunction( item, name ); + if ( !isNull( result ) ) return result; + } + } + } + } + return; + } + +} diff --git a/test/tickets/LDEV5990/hintedParams.cfc b/test/tickets/LDEV5990/hintedParams.cfc new file mode 100644 index 00000000000..c0fd74a575b --- /dev/null +++ b/test/tickets/LDEV5990/hintedParams.cfc @@ -0,0 +1,21 @@ +component { + + function withHint( + required string name hint="The user's full name" + ) { + return name; + } + + function withDefault( + string greeting default="Hello" + ) { + return greeting; + } + + function withBoth( + required string message hint="The message to display" default="Welcome" + ) { + return message; + } + +} From 1d4bf6e85e20787d52bfeba322e39016a80b0b5e Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Mon, 15 Dec 2025 14:21:39 +0100 Subject: [PATCH 18/71] LDEV-5895 AST include java function source --- .../bytecode/statement/TagIsland.java | 2 +- .../bytecode/statement/udf/Function.java | 9 ++++++++ .../expression/AbstrCFMLExprTransformer.java | 2 +- .../script/AbstrCFMLScriptTransformer.java | 21 +++++++++++-------- test/tickets/LDEV5985.cfc | 14 +++++++++++++ 5 files changed, 37 insertions(+), 11 deletions(-) diff --git a/core/src/main/java/lucee/transformer/bytecode/statement/TagIsland.java b/core/src/main/java/lucee/transformer/bytecode/statement/TagIsland.java index 7f80781e24e..f404b248a83 100644 --- a/core/src/main/java/lucee/transformer/bytecode/statement/TagIsland.java +++ b/core/src/main/java/lucee/transformer/bytecode/statement/TagIsland.java @@ -50,7 +50,7 @@ public void dump(Struct sct) { stmt.dump(stmtSct); bodyArr.appendEL(stmtSct); } - sct.setEL("body", bodyArr); + sct.setEL(KeyConstants._body, bodyArr); } // Body interface methods - delegate to inner body diff --git a/core/src/main/java/lucee/transformer/bytecode/statement/udf/Function.java b/core/src/main/java/lucee/transformer/bytecode/statement/udf/Function.java index 80672051ff6..62de87dee04 100644 --- a/core/src/main/java/lucee/transformer/bytecode/statement/udf/Function.java +++ b/core/src/main/java/lucee/transformer/bytecode/statement/udf/Function.java @@ -153,6 +153,7 @@ public abstract class Function extends StatementBaseNoFinal implements Opcodes, Literal cachedWithin; int modifier; protected JavaFunction jf; + protected String rawJavaSource; // raw Java source for AST round-tripping // private final Root root; protected int index = -1; @@ -614,6 +615,10 @@ public void setJavaFunction(JavaFunction jf) { this.jf = jf; } + public void setRawJavaSource(String rawJavaSource) { + this.rawJavaSource = rawJavaSource; + } + public void setIndex(int index) { this.index = index; } @@ -733,6 +738,10 @@ public void dump(Struct sct, String type) { if (body != null) { Struct s = new StructImpl(Struct.TYPE_LINKED); body.dump(s); + // For Java functions, include raw Java source for round-tripping + if (rawJavaSource != null) { + s.setEL(KeyConstants._raw, rawJavaSource); + } sct.setEL(KeyConstants._body, s); } } diff --git a/core/src/main/java/lucee/transformer/cfml/expression/AbstrCFMLExprTransformer.java b/core/src/main/java/lucee/transformer/cfml/expression/AbstrCFMLExprTransformer.java index 3287639985b..f5b0a5fc035 100755 --- a/core/src/main/java/lucee/transformer/cfml/expression/AbstrCFMLExprTransformer.java +++ b/core/src/main/java/lucee/transformer/cfml/expression/AbstrCFMLExprTransformer.java @@ -1865,7 +1865,7 @@ private FunctionMember getFunctionMember(Data data, final ExprString name, boole arg = it.next(); // Skip hidden args (internal metadata like __filename, __mapping) in ast mode only if (data.ast && arg.isHidden()) continue; - if (arg.getDefaultValue() != null)bif.addArgument(new NamedArgumentImpl(data.factory.createLitString(arg.getName()), + if (arg.getDefaultValue() != null) bif.addArgument(new NamedArgumentImpl(data.factory.createLitString(arg.getName()), data.factory.createLitString(arg.getDefaultValue()), arg.getTypeAsString(), false)); } } diff --git a/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java b/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java index 2bad97c1a18..87f52bffff9 100755 --- a/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java +++ b/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java @@ -1217,8 +1217,8 @@ protected final Function closurePart(Data data, String id, int access, int modif // TODO cachedwithin - func.setJavaFunction(java(data, body, id, access, modifier, hint, args, attrs, rtnType, output, bufferOutput, displayName, description, returnFormat, secureJson, - verifyClient, localMode)); + java(data, func, body, id, access, modifier, hint, args, attrs, rtnType, output, bufferOutput, displayName, description, returnFormat, secureJson, + verifyClient, localMode); } else { func.register(data.page); @@ -1261,7 +1261,7 @@ private Attribute[] remove(Attribute[] attrs, String name) { return list.toArray(new Attribute[list.size()]); } - private JavaFunction java(Data data, Body body, String functionName, int access, int modifier, String hint, ArrayList args, Attribute[] attrs, String rtnType, + private void java(Data data, Function func, Body body, String functionName, int access, int modifier, String hint, ArrayList args, Attribute[] attrs, String rtnType, Boolean output, Boolean bufferOutput, String displayName, String description, int returnFormat, Boolean secureJson, Boolean verifyClient, int localMode) throws TemplateException { @@ -1286,22 +1286,25 @@ private JavaFunction java(Data data, Body body, String functionName, int access, SourceCode sc = data.srcCode; Position start = sc.getPosition(); findTheEnd(data, start.line); + Position end = sc.getPosition(); + String javaCode = sc.substring(start.pos, end.pos - start.pos); - // In AST mode without PageSource, we can't compile - just return null + // Always store raw Java source for AST round-tripping + func.setRawJavaSource(javaCode); + + // In AST mode without PageSource, we can't compile - just store raw source // The function body has already been parsed past by findTheEnd if (ps == null) { - return null; + return; } - - Position end = sc.getPosition(); - String javaCode = sc.substring(start.pos, end.pos - start.pos); try { String id = data.page.registerJavaFunctionName(functionName); lucee.commons.lang.compiler.SourceCode _sc = fd.createSourceCode(ps, javaCode, id, functionName, access, modifier, hint, args, output, bufferOutput, displayName, description, returnFormat, secureJson, verifyClient, localMode); JavaFunction jf = new JavaFunction(ps, _sc, CompilerFactory.getInstance().compile((ConfigPro) data.config, _sc)); - return jf; + func.setJavaFunction(jf); + return; } catch (JavaCompilerException e) { Throwable cause = e.getCause(); diff --git a/test/tickets/LDEV5985.cfc b/test/tickets/LDEV5985.cfc index af3a8749ab2..c6efe81bc8a 100644 --- a/test/tickets/LDEV5985.cfc +++ b/test/tickets/LDEV5985.cfc @@ -28,6 +28,20 @@ component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { expect( funcDecl.type ).toBe( "FunctionDeclaration" ); }); + it( "should preserve raw Java source in AST body for round-tripping", function() { + var code = fileRead( variables.testDir & "javaFunction.cfm" ); + var ast = astFromString( code ); + + // Find the function declaration + var funcDecl = findNodeByType( ast, "FunctionDeclaration" ); + expect( isNull( funcDecl ) ).toBeFalse( "FunctionDeclaration should be present" ); + + // The body should contain the raw Java source for round-tripping + expect( funcDecl ).toHaveKey( "body" ); + expect( funcDecl.body ).toHaveKey( "raw" ); + expect( funcDecl.body.raw ).toInclude( "return i*2" ); + }); + }); } From f48c720435be65f898e0d3a119da5f85222a3981 Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Wed, 17 Dec 2025 22:45:56 +0100 Subject: [PATCH 19/71] LDEV-6008 PreserveSingleQuotes evaluated in cfquery AST --- .../cfml/evaluator/impl/Query.java | 6 ++ test/tickets/LDEV6008.cfc | 84 +++++++++++++++++++ test/tickets/LDEV6008/dumpAST.cfm | 43 ++++++++++ test/tickets/LDEV6008/queryWithPSQ.cfm | 1 + test/tickets/LDEV6008/regularOutput.cfm | 1 + 5 files changed, 135 insertions(+) create mode 100644 test/tickets/LDEV6008.cfc create mode 100644 test/tickets/LDEV6008/dumpAST.cfm create mode 100644 test/tickets/LDEV6008/queryWithPSQ.cfm create mode 100644 test/tickets/LDEV6008/regularOutput.cfm diff --git a/core/src/main/java/lucee/transformer/cfml/evaluator/impl/Query.java b/core/src/main/java/lucee/transformer/cfml/evaluator/impl/Query.java index 46132b79ea8..62518d954fb 100755 --- a/core/src/main/java/lucee/transformer/cfml/evaluator/impl/Query.java +++ b/core/src/main/java/lucee/transformer/cfml/evaluator/impl/Query.java @@ -37,9 +37,11 @@ import lucee.transformer.expression.literal.Literal; import lucee.transformer.expression.var.Member; import lucee.transformer.expression.var.Variable; +import lucee.transformer.Page; import lucee.transformer.statement.Statement; import lucee.transformer.statement.tag.Attribute; import lucee.transformer.statement.tag.Tag; +import lucee.transformer.bytecode.util.ASMUtil; /** * sign print outs for preserver @@ -56,6 +58,10 @@ public void evaluate(Tag tag) throws EvaluatorException { // string if (body != null) { + // In AST mode, don't transform the body - preserve original structure + Page page = ASMUtil.getAncestorPage(tag, null); + if (page != null && page.isAST()) return; + List stats = body.getStatements(); if (stats != null) translateChildren(body.getStatements().iterator()); } diff --git a/test/tickets/LDEV6008.cfc b/test/tickets/LDEV6008.cfc new file mode 100644 index 00000000000..f66d85e8c1c --- /dev/null +++ b/test/tickets/LDEV6008.cfc @@ -0,0 +1,84 @@ +component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { + + function run( testResults, testBox ) { + + describe( "LDEV-6008: PreserveSingleQuotes evaluated in cfquery AST", function() { + + it( "should preserve PreserveSingleQuotes function call in cfquery body", function() { + // Use a literal string argument so it doesn't try to resolve a variable + var code = '##PreserveSingleQuotes( "test" )##'; + var ast = astFromString( code ); + + expect( ast.body ).toHaveLength( 1 ); + expect( ast.body[1].type ).toBe( "CFMLTag" ); + expect( ast.body[1].name ).toBe( "query" ); + + // The body should contain an ExpressionStatement (interpolation) + var body = ast.body[1].body; + expect( body.body ).toBeArray(); + expect( body.body ).toHaveLength( 1, "Should have one expression" ); + + var stmt = body.body[1]; + expect( stmt.type ).toBe( "ExpressionStatement" ); + + var expr = stmt.expression; + // Expression is wrapped in CastExpression (string cast), CallExpression is inside argument + expect( expr.type ).toBe( "CastExpression" ); + expect( expr.argument.type ).toBe( "CallExpression", + "Expected CallExpression inside CastExpression but got #expr.argument.type# - PreserveSingleQuotes was evaluated during AST generation" ); + expect( expr.argument.callee.name ).toBe( "preservesinglequotes", + "Function name should be preservesinglequotes" ); + }); + + it( "should not evaluate expressions during AST generation", function() { + // This demonstrates that PreserveSingleQuotes is being EXECUTED during parsing + // Even though we're just generating an AST, Lucee evaluates the function + var code = '##PreserveSingleQuotes( "original" )##'; + var ast = astFromString( code ); + + var body = ast.body[1].body; + var stmt = body.body[1]; + var expr = stmt.expression; + + // If the bug is present, expr will be StringLiteral with value="original" + // If fixed, expr should be CastExpression with argument.type="CallExpression" + if ( expr.type == "StringLiteral" ) { + fail( "BUG: PreserveSingleQuotes was evaluated during AST generation. " & + "Expression reduced to StringLiteral '#expr.value#' instead of CallExpression" ); + } + + expect( expr.type ).toBe( "CastExpression" ); + expect( expr.argument.type ).toBe( "CallExpression" ); + expect( expr.argument.callee.name ).toBe( "preservesinglequotes" ); + }); + + it( "preserves nested PreserveSingleQuotes when wrapped in another function", function() { + // When PreserveSingleQuotes is wrapped in trim(), it's preserved + // This test verifies that nested calls work + var code = '##trim( PreserveSingleQuotes( "test" ) )##'; + var ast = astFromString( code ); + + var body = ast.body[1].body; + var stmt = body.body[1]; + var expr = stmt.expression; + + // Expression is wrapped in CastExpression + expect( expr.type ).toBe( "CastExpression" ); + + // The outer trim() should be there as CallExpression inside the cast + var trimCall = expr.argument; + expect( trimCall.type ).toBe( "CallExpression" ); + expect( trimCall.callee.name ).toBe( "trim" ); + + // The argument to trim() should be CallExpression (PreserveSingleQuotes) + var innerArg = trimCall.arguments[1]; + expect( innerArg.type ).toBe( "CallExpression", + "Inner PreserveSingleQuotes should be CallExpression, got #innerArg.type#" ); + expect( innerArg.callee.name ).toBe( "preservesinglequotes" ); + }); + + }); + + } + +} diff --git a/test/tickets/LDEV6008/dumpAST.cfm b/test/tickets/LDEV6008/dumpAST.cfm new file mode 100644 index 00000000000..ed19d8409ec --- /dev/null +++ b/test/tickets/LDEV6008/dumpAST.cfm @@ -0,0 +1,43 @@ + +testDir = getDirectoryFromPath( getCurrentTemplatePath() ); + +systemOutput( "=== QUERY WITH PSQ ===", true ); +ast = astFromPath( testDir & "queryWithPSQ.cfm" ); + +systemOutput( "=== LDEV-6008 AST DUMP ===", true ); +systemOutput( serializeJSON( ast, "struct" ), true ); +systemOutput( "=== END ===", true ); + +// Drill into the structure +systemOutput( "", true ); +systemOutput( "Body length: " & arrayLen( ast.body ), true ); +if ( arrayLen( ast.body ) > 0 ) { + queryTag = ast.body[1]; + systemOutput( "Tag type: " & queryTag.type, true ); + systemOutput( "Tag name: " & ( queryTag.name ?: "n/a" ), true ); + + if ( structKeyExists( queryTag, "body" ) && structKeyExists( queryTag.body, "body" ) ) { + body = queryTag.body.body; + systemOutput( "Body items: " & arrayLen( body ), true ); + for ( item in body ) { + systemOutput( " Item type: " & item.type, true ); + if ( structKeyExists( item, "expression" ) ) { + expr = item.expression; + systemOutput( " Expression type: " & expr.type, true ); + systemOutput( " Expression keys: " & structKeyList( expr ), true ); + if ( structKeyExists( expr, "callee" ) ) { + systemOutput( " Callee name: " & ( expr.callee.name ?: "n/a" ), true ); + } + if ( structKeyExists( expr, "expression" ) ) { + systemOutput( " Inner expression type: " & expr.expression.type, true ); + } + } + } + } +} + +systemOutput( "", true ); +systemOutput( "=== REGULAR OUTPUT WITH PSQ ===", true ); +ast2 = astFromPath( testDir & "regularOutput.cfm" ); +systemOutput( serializeJSON( ast2, "struct" ), true ); + diff --git a/test/tickets/LDEV6008/queryWithPSQ.cfm b/test/tickets/LDEV6008/queryWithPSQ.cfm new file mode 100644 index 00000000000..cd5131001cd --- /dev/null +++ b/test/tickets/LDEV6008/queryWithPSQ.cfm @@ -0,0 +1 @@ +#PreserveSingleQuotes( "test" )# diff --git a/test/tickets/LDEV6008/regularOutput.cfm b/test/tickets/LDEV6008/regularOutput.cfm new file mode 100644 index 00000000000..733c4c21de5 --- /dev/null +++ b/test/tickets/LDEV6008/regularOutput.cfm @@ -0,0 +1 @@ +#PreserveSingleQuotes( "test" )# From 4ef4683ea2d14a0ff29c5d9beccb7536d1a4b5ec Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Wed, 17 Dec 2025 23:08:42 +0100 Subject: [PATCH 20/71] LDEV-6007 AST Component inside cfscript becomes sibling instead of child --- .../runtime/compiler/CFMLCompilerImpl.java | 7 +- .../src/main/java/lucee/transformer/Page.java | 2 + .../lucee/transformer/bytecode/PageImpl.java | 10 +++ .../cfml/evaluator/impl/Component.java | 45 ++++++++---- .../transformer/cfml/tag/CFMLTransformer.java | 16 ++++- test/tickets/LDEV6007.cfc | 72 +++++++++++++++++++ test/tickets/LDEV6007/scriptComponent.cfc | 3 + 7 files changed, 135 insertions(+), 20 deletions(-) create mode 100644 test/tickets/LDEV6007.cfc create mode 100644 test/tickets/LDEV6007/scriptComponent.cfc diff --git a/core/src/main/java/lucee/runtime/compiler/CFMLCompilerImpl.java b/core/src/main/java/lucee/runtime/compiler/CFMLCompilerImpl.java index 216d5e6801a..332ce424c8a 100755 --- a/core/src/main/java/lucee/runtime/compiler/CFMLCompilerImpl.java +++ b/core/src/main/java/lucee/runtime/compiler/CFMLCompilerImpl.java @@ -84,7 +84,7 @@ public Struct ast(ConfigPro config, PageSource ps, boolean ignoreScopes) throws BytecodeFactory factory = BytecodeFactory.getInstance(config); // , cwi.getFLDs() - PageImpl page = ((PageImpl) cfmlTagTransformer.transform(factory, config, ps, config.getTLDs(), config.getFLDs(), false, ignoreScopes)); + PageImpl page = ((PageImpl) cfmlTagTransformer.transform(factory, config, ps, config.getTLDs(), config.getFLDs(), false, ignoreScopes, true)); Struct root = new StructImpl(Struct.TYPE_LINKED); page.dump(root); @@ -107,9 +107,8 @@ public Struct ast(ConfigPro config, PageSource ps, boolean ignoreScopes) throws public Struct ast(ConfigPro config, SourceCode sc, boolean ignoreScopes, Boolean script) throws PageException { BytecodeFactory factory = BytecodeFactory.getInstance(config); - // TODO auto when script is null - if (script != null && script) { + if (script) { TagLibTag scriptTag = CFMLTransformer.getTLT(sc, Constants.CFML_SCRIPT_TAG_NAME, config.getIdentification()); sc.setPos(0); @@ -124,7 +123,7 @@ public Struct ast(ConfigPro config, SourceCode sc, boolean ignoreScopes, Boolean Struct root = new StructImpl(Struct.TYPE_LINKED); page.dump(root); - if (script != null && script) { + if (script) { extractScriptTagInRoot(root); } diff --git a/core/src/main/java/lucee/transformer/Page.java b/core/src/main/java/lucee/transformer/Page.java index 7476843d5fb..071acba3c64 100644 --- a/core/src/main/java/lucee/transformer/Page.java +++ b/core/src/main/java/lucee/transformer/Page.java @@ -11,6 +11,8 @@ public interface Page extends Root { public boolean isComponent(); + public boolean isAST(); + public SourceCode getSourceCode(); public Config getConfig(); diff --git a/core/src/main/java/lucee/transformer/bytecode/PageImpl.java b/core/src/main/java/lucee/transformer/bytecode/PageImpl.java index 3f853803f72..ecbb5eeb61e 100755 --- a/core/src/main/java/lucee/transformer/bytecode/PageImpl.java +++ b/core/src/main/java/lucee/transformer/bytecode/PageImpl.java @@ -264,6 +264,7 @@ public final class PageImpl extends BodyBase implements Page { private int hash; private List javaFunctions; private Set javaFunctionNames; + private boolean forAST; /** * @param factory @@ -1763,6 +1764,15 @@ public boolean isPage() { return getTagCFObject(null) == null; } + @Override + public boolean isAST() { + return forAST; + } + + public void setAST(boolean forAST) { + this.forAST = forAST; + } + /** * @return the lastModifed */ diff --git a/core/src/main/java/lucee/transformer/cfml/evaluator/impl/Component.java b/core/src/main/java/lucee/transformer/cfml/evaluator/impl/Component.java index a296d76075c..e39f1717a94 100755 --- a/core/src/main/java/lucee/transformer/cfml/evaluator/impl/Component.java +++ b/core/src/main/java/lucee/transformer/cfml/evaluator/impl/Component.java @@ -87,22 +87,26 @@ public void evaluate(Tag tag, TagLibTag tlt) throws EvaluatorException { if (p != null && (pPage = p.getParent()) instanceof Page && p.getTagLibTag().getName().equalsIgnoreCase(Constants.CFML_SCRIPT_TAG_NAME)) { // chnaged page = (Page) pPage; - // move imports from script to component body - List children = p.getBody().getStatements(); - Iterator it = children.iterator(); - Statement stat; - Tag t; - while (it.hasNext()) { - stat = it.next(); - if (!(stat instanceof Tag)) continue; - t = (Tag) stat; - if (t.getTagLibTag().getName().equals("import")) { - tag.getBody().addStatement(t); + + // In AST mode, don't move component out of cfscript - preserve source structure + if (!page.isAST()) { + // move imports from script to component body + List children = p.getBody().getStatements(); + Iterator it = children.iterator(); + Statement stat; + Tag t; + while (it.hasNext()) { + stat = it.next(); + if (!(stat instanceof Tag)) continue; + t = (Tag) stat; + if (t.getTagLibTag().getName().equals("import")) { + tag.getBody().addStatement(t); + } } - } - // move to page - ASMUtil.move(tag, page); + // move to page + ASMUtil.move(tag, page); + } // if(!inline)ASMUtil.replace(p, tag, false); } @@ -300,6 +304,19 @@ private boolean isMainComponent(Page page, TagCIObject comp) { while (it.hasNext()) { Statement s = it.next(); if (s instanceof TagCIObject) return s == comp; + // In AST mode, component may be inside cfscript - check script body too + if (page.isAST() && s instanceof Tag) { + Tag tag = (Tag) s; + if (tag.getTagLibTag() != null && Constants.CFML_SCRIPT_TAG_NAME.equalsIgnoreCase(tag.getTagLibTag().getName())) { + if (tag.getBody() != null) { + Iterator bodyIt = tag.getBody().getStatements().iterator(); + while (bodyIt.hasNext()) { + Statement bs = bodyIt.next(); + if (bs instanceof TagCIObject) return bs == comp; + } + } + } + } } return false; } diff --git a/core/src/main/java/lucee/transformer/cfml/tag/CFMLTransformer.java b/core/src/main/java/lucee/transformer/cfml/tag/CFMLTransformer.java index 2f21f0aefca..a2d32b14d29 100755 --- a/core/src/main/java/lucee/transformer/cfml/tag/CFMLTransformer.java +++ b/core/src/main/java/lucee/transformer/cfml/tag/CFMLTransformer.java @@ -46,6 +46,7 @@ import lucee.transformer.Factory; import lucee.transformer.Page; import lucee.transformer.Position; +import lucee.transformer.bytecode.PageImpl; import lucee.transformer.bytecode.statement.StatementBase; import lucee.transformer.bytecode.statement.tag.TagBase; import lucee.transformer.bytecode.statement.tag.TagFunction; @@ -142,6 +143,11 @@ public CFMLTransformer(boolean codeIsland) { */ public Page transform(Factory factory, ConfigPro config, PageSource ps, TagLib[] tlibs, FunctionLib flibs, boolean returnValue, boolean ignoreScopes) throws TemplateException, IOException { + return transform(factory, config, ps, tlibs, flibs, returnValue, ignoreScopes, false); + } + + public Page transform(Factory factory, ConfigPro config, PageSource ps, TagLib[] tlibs, FunctionLib flibs, boolean returnValue, boolean ignoreScopes, boolean ast) + throws TemplateException, IOException { Page p; SourceCode sc; @@ -156,7 +162,7 @@ public Page transform(Factory factory, ConfigPro config, PageSource ps, TagLib[] boolean hasWriteLog = false; boolean hasCharset = false; boolean hasUpper = false; - boolean allowUnknownTags = false; + boolean allowUnknownTags = ast; while (true) { PageSourceCode psc = null; try { @@ -269,7 +275,8 @@ else if (isCFMLCompExt) { if (_p != null && !_p.isPage()) return _p; } - if (isCFMLCompExt && !p.isComponent() && !p.isInterface()) { + // In AST mode, component stays inside cfscript so skip this validation + if (!ast && isCFMLCompExt && !p.isComponent() && !p.isInterface()) { String msg = "template [" + ps.getDisplayPath() + "] must contain a component or an interface."; if (sc != null) throw new TemplateException(sc, msg); throw new TemplateException(msg); @@ -339,6 +346,11 @@ public Page transform(Factory factory, ConfigPro config, SourceCode sc, TagLib[] // sc.getWriteLog(),config.getSuppressWSBeforeArg(), config.getDefaultFunctionOutput(), returnValue, // ignoreScope); + // allowUnknownTags is used as ast flag - mark the page for AST mode + if (allowUnknownTags && page instanceof PageImpl) { + ((PageImpl) page).setAST(true); + } + TransfomerSettings settings = new TransfomerSettings(dnuc, config.getHandleUnQuotedAttrValueAsString(), ignoreScope); Data data = new Data(factory, config, page, sc, new EvaluatorPool(), settings, _tlibs, flibs, config.getCoreTagLib().getScriptTags(), false, hasWriteLog, hasUpper, hasCharset, allowUnknownTags); diff --git a/test/tickets/LDEV6007.cfc b/test/tickets/LDEV6007.cfc new file mode 100644 index 00000000000..0eba91f7d9b --- /dev/null +++ b/test/tickets/LDEV6007.cfc @@ -0,0 +1,72 @@ +component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { + + variables.testDir = getDirectoryFromPath( getCurrentTemplatePath() ) & "LDEV6007/"; + + function run( testResults, testBox ) { + + describe( "LDEV-6007: Component inside cfscript becomes sibling instead of child", function() { + + it( "should nest component inside cfscript body", function() { + var ast = astFromPath( variables.testDir & "scriptComponent.cfc" ); + + // The AST should have a single top-level element: cfscript + expect( ast.type ).toBe( "Program" ); + expect( ast.body ).toBeArray(); + + // Find the cfscript tag + var cfscriptTag = findTagByName( ast, "script" ); + expect( cfscriptTag ).notToBeNull( "cfscript tag should be present in AST" ); + + // The component should be INSIDE the cfscript body, not as a sibling + var componentInBody = findTagByName( cfscriptTag, "component" ); + + // Currently fails: component is a sibling, not a child + expect( isNull( componentInBody ) ).toBeFalse( + "component should be nested inside cfscript body, not as a sibling" ); + }); + + it( "should not have component as sibling to cfscript", function() { + var ast = astFromPath( variables.testDir & "scriptComponent.cfc" ); + + // Count top-level CFMLTag nodes + var topLevelTags = []; + for ( var node in ast.body ) { + if ( ( node.type ?: "" ) == "CFMLTag" ) { + arrayAppend( topLevelTags, node.name ?: "unknown" ); + } + } + + // There should only be one top-level tag (cfscript), not two (cfscript + component) + expect( arrayLen( topLevelTags ) ).toBe( 1, + "Expected 1 top-level tag (cfscript) but found: " & arrayToList( topLevelTags ) ); + }); + + }); + + } + + /** + * Recursively find a tag by name + */ + private function findTagByName( required struct node, required string tagName ) { + if ( ( node.type ?: "" ) == "CFMLTag" && ( node.name ?: "" ) == arguments.tagName ) { + return node; + } + for ( var key in node ) { + var val = node[ key ]; + if ( isStruct( val ) ) { + var result = findTagByName( val, arguments.tagName ); + if ( !isNull( result ) ) return result; + } else if ( isArray( val ) ) { + for ( var item in val ) { + if ( isStruct( item ) ) { + var result = findTagByName( item, arguments.tagName ); + if ( !isNull( result ) ) return result; + } + } + } + } + return; + } + +} diff --git a/test/tickets/LDEV6007/scriptComponent.cfc b/test/tickets/LDEV6007/scriptComponent.cfc new file mode 100644 index 00000000000..cda8daa27ee --- /dev/null +++ b/test/tickets/LDEV6007/scriptComponent.cfc @@ -0,0 +1,3 @@ +component extends="foo" { + function bar() {} +} From 09990670588d4dcbb50e1c21b49676aaa2cb8049 Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Thu, 18 Dec 2025 16:44:40 +0100 Subject: [PATCH 21/71] LDEV-6007 AST unwrap script components from cfscript wrapper Added wrappedInScript flag to SourceCode to track when parser wraps script content in cfscript tags, replacing the fragile sourceOffset==10 hack. AST output now correctly returns component at root for .cfc files. --- .claude/settings.local.json | 8 +++ .../runtime/compiler/CFMLCompilerImpl.java | 22 ++++--- .../transformer/cfml/tag/CFMLTransformer.java | 3 + .../lucee/transformer/util/SourceCode.java | 11 ++++ test/tickets/LDEV6006.cfc | 59 +++++++++++++++++++ test/tickets/LDEV6006/commentInString.cfc | 8 +++ test/tickets/LDEV6006/debugAST.cfm | 47 +++++++++++++++ test/tickets/LDEV6007.cfc | 40 ++++++------- 8 files changed, 167 insertions(+), 31 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 test/tickets/LDEV6006.cfc create mode 100644 test/tickets/LDEV6006/commentInString.cfc create mode 100644 test/tickets/LDEV6006/debugAST.cfm diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000000..643e37059af --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Bash(mkdir:*)", + "Bash(curl:*)" + ] + } +} diff --git a/core/src/main/java/lucee/runtime/compiler/CFMLCompilerImpl.java b/core/src/main/java/lucee/runtime/compiler/CFMLCompilerImpl.java index 332ce424c8a..76a0882149a 100755 --- a/core/src/main/java/lucee/runtime/compiler/CFMLCompilerImpl.java +++ b/core/src/main/java/lucee/runtime/compiler/CFMLCompilerImpl.java @@ -88,13 +88,11 @@ public Struct ast(ConfigPro config, PageSource ps, boolean ignoreScopes) throws Struct root = new StructImpl(Struct.TYPE_LINKED); page.dump(root); - // TODO better solution than simply look at the offset from script - if (page.getSourceCode().getSourceOffset() == 10) { - + // If parser wrapped script content in cfscript tags, unwrap it in the AST output + if (page.getSourceCode().isWrappedInScript()) { boolean isCFMLCompExt = Constants.isCFMLComponentExtension(ResourceUtil.getExtension(ps.getResource(), "")); // in case of a component Lucee moves the component to the root, so at the first position is just an - // empty script, we simply have to emove this - // TODO remove the script after moving in the parser + // empty script, we simply have to remove this if (isCFMLCompExt) { removeEmptyScriptTag(root); } @@ -149,14 +147,22 @@ private void extractScriptTagInRoot(Struct root) { private void removeEmptyScriptTag(Struct root) { Array body = Caster.toArray(root.get(KeyConstants._body, null), null); - if (body != null && body.size() > 1) { + if (body != null && body.size() >= 1) { Struct first = Caster.toStruct(body.get(1, null), null); if (first != null) { if ("cfscript".equalsIgnoreCase(Caster.toString(first.get("fullname", null), null))) { Struct body2 = Caster.toStruct(first.get(KeyConstants._body, null), null); Array body3 = Caster.toArray(body2.get(KeyConstants._body, null), null); - if (body3 != null && body3.size() == 0) { - body.removeEL(1); + if (body3 != null) { + if (body3.size() == 0) { + // Empty cfscript - just remove it + body.removeEL(1); + } + else { + // cfscript has content (component) - extract it + // Replace root body with cfscript contents + root.setEL(KeyConstants._body, body3); + } } } } diff --git a/core/src/main/java/lucee/transformer/cfml/tag/CFMLTransformer.java b/core/src/main/java/lucee/transformer/cfml/tag/CFMLTransformer.java index a2d32b14d29..365aa7e2a04 100755 --- a/core/src/main/java/lucee/transformer/cfml/tag/CFMLTransformer.java +++ b/core/src/main/java/lucee/transformer/cfml/tag/CFMLTransformer.java @@ -192,6 +192,7 @@ else if (isCFMLCompExt) { text = "<" + scriptTag.getFullName() + ">" + text + "\n"; int sourceOffset = ("<" + scriptTag.getFullName() + ">").length(); sc = new PageSourceCode(ps, text, charset, writeLog, sourceOffset); + sc.setWrappedInScript(true); wrapped = true; } @@ -238,6 +239,7 @@ else if (isCFMLCompExt) { String text = "<" + scriptTag.getFullName() + ">" + original.getText() + "\n"; int sourceOffset = ("<" + scriptTag.getFullName() + ">").length(); sc = new PageSourceCode(ps, text, charset, writeLog, sourceOffset); + sc.setWrappedInScript(true); try { while (true) { @@ -245,6 +247,7 @@ else if (isCFMLCompExt) { sc = new PageSourceCode(ps, charset, writeLog); text = "<" + scriptTag.getFullName() + ">" + sc.getText() + "\n"; sc = new PageSourceCode(ps, text, charset, writeLog, sourceOffset); + sc.setWrappedInScript(true); } try { _p = transform(factory, config, sc, tlibs, flibs, ps.getResource().lastModified(), dotUpper, returnValue, ignoreScopes, hasWriteLog, hasUpper, hasCharset, diff --git a/core/src/main/java/lucee/transformer/util/SourceCode.java b/core/src/main/java/lucee/transformer/util/SourceCode.java index cd44d850c9e..f366f98c3fb 100755 --- a/core/src/main/java/lucee/transformer/util/SourceCode.java +++ b/core/src/main/java/lucee/transformer/util/SourceCode.java @@ -40,6 +40,9 @@ public class SourceCode { private int hash; private SourceCode parent; private int sourceOffset; + // Set to true when the parser wraps script content in tags for parsing. + // Used by AST generation to know it should unwrap the cfscript in the output. + private boolean wrappedInScript; public SourceCode(SourceCode parent, String strText, boolean writeLog) { this(parent, strText, writeLog, 0); @@ -959,4 +962,12 @@ public int hashCode() { public int getSourceOffset() { return sourceOffset; } + + public boolean isWrappedInScript() { + return wrappedInScript; + } + + public void setWrappedInScript(boolean wrappedInScript) { + this.wrappedInScript = wrappedInScript; + } } \ No newline at end of file diff --git a/test/tickets/LDEV6006.cfc b/test/tickets/LDEV6006.cfc new file mode 100644 index 00000000000..154bd5eb9ef --- /dev/null +++ b/test/tickets/LDEV6006.cfc @@ -0,0 +1,59 @@ +component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { + + variables.testDir = getDirectoryFromPath( getCurrentTemplatePath() ) & "LDEV6006/"; + + function run( testResults, testBox ) { + + describe( "LDEV-6006: astFromString mishandles CFML comment syntax in string literals", function() { + + it( "should parse component with $"; } }'; + var ast = astFromString( code ); + + // astFromString auto-detects script mode, component is at root + expect( ast.body ).toHaveLength( 1, + "Should have single element, got #arrayLen( ast.body )# elements" ); + expect( ast.body[1].type ).toBe( "CFMLTag", + "Should be CFMLTag, got #ast.body[1].type#" ); + expect( ast.body[1].name ).toBe( "component", + "Should be component tag, got #ast.body[1].name#" ); + + // Check the string literal preserves the " ) { + if ( reFindNoCase( "^$", line ) ) { + return true; + } + return false; + } +} diff --git a/test/tickets/LDEV6006/debugAST.cfm b/test/tickets/LDEV6006/debugAST.cfm new file mode 100644 index 00000000000..fa2f76c3f6f --- /dev/null +++ b/test/tickets/LDEV6006/debugAST.cfm @@ -0,0 +1,47 @@ + +// Test 1: Pure script mode - works +systemOutput( "=== TEST 1: Script mode ===", true ); +try { + code1 = 'x = "test $"; } }'; + ast2 = astFromString( code2 ); + systemOutput( "Result body length: " & arrayLen( ast2.body ), true ); + systemOutput( "Full AST: " & serializeJSON( ast2 ), true ); +} catch( any e ) { + systemOutput( "ERROR: " & e.message, true ); +} + +// Test 3: Component WITH script mode +systemOutput( "", true ); +systemOutput( "=== TEST 3: Component WITH script mode ===", true ); +try { + code3 = 'component { function test() { x = "^$"; } }'; + ast3 = astFromString( code3, "script" ); + systemOutput( "Result body length: " & arrayLen( ast3.body ), true ); + systemOutput( "First body type: " & ast3.body[1].type, true ); +} catch( any e ) { + systemOutput( "ERROR: " & e.message, true ); +} + +// Test 4: What if we manually wrap in cfscript? +systemOutput( "", true ); +systemOutput( "=== TEST 4: Manually wrapped in cfscript (tag mode) ===", true ); +try { + code4 = 'component { function test() { x = "^$"; } }'; + ast4 = astFromString( code4 ); + systemOutput( "Result body length: " & arrayLen( ast4.body ), true ); + systemOutput( "Full AST: " & serializeJSON( ast4 ), true ); +} catch( any e ) { + systemOutput( "ERROR: " & e.message, true ); +} + diff --git a/test/tickets/LDEV6007.cfc b/test/tickets/LDEV6007.cfc index 0eba91f7d9b..7873b47e92c 100644 --- a/test/tickets/LDEV6007.cfc +++ b/test/tickets/LDEV6007.cfc @@ -6,39 +6,33 @@ component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { describe( "LDEV-6007: Component inside cfscript becomes sibling instead of child", function() { - it( "should nest component inside cfscript body", function() { + it( "should return component at root for script-based .cfc files", function() { + // After fixing Quirk #22, astFromPath on .cfc files should return + // the component directly at root, without cfscript wrapper var ast = astFromPath( variables.testDir & "scriptComponent.cfc" ); - // The AST should have a single top-level element: cfscript expect( ast.type ).toBe( "Program" ); expect( ast.body ).toBeArray(); + expect( ast.body ).toHaveLength( 1, "Should have single body element" ); - // Find the cfscript tag - var cfscriptTag = findTagByName( ast, "script" ); - expect( cfscriptTag ).notToBeNull( "cfscript tag should be present in AST" ); - - // The component should be INSIDE the cfscript body, not as a sibling - var componentInBody = findTagByName( cfscriptTag, "component" ); - - // Currently fails: component is a sibling, not a child - expect( isNull( componentInBody ) ).toBeFalse( - "component should be nested inside cfscript body, not as a sibling" ); + // Component should be directly at root, not wrapped in cfscript + expect( ast.body[1].type ).toBe( "CFMLTag" ); + expect( ast.body[1].name ).toBe( "component", + "Component should be at root, got: #ast.body[1].name#" ); }); - it( "should not have component as sibling to cfscript", function() { + it( "should have function inside the component body", function() { var ast = astFromPath( variables.testDir & "scriptComponent.cfc" ); - // Count top-level CFMLTag nodes - var topLevelTags = []; - for ( var node in ast.body ) { - if ( ( node.type ?: "" ) == "CFMLTag" ) { - arrayAppend( topLevelTags, node.name ?: "unknown" ); - } - } + // The component should contain the function + var component = ast.body[1]; + expect( component.name ).toBe( "component" ); - // There should only be one top-level tag (cfscript), not two (cfscript + component) - expect( arrayLen( topLevelTags ) ).toBe( 1, - "Expected 1 top-level tag (cfscript) but found: " & arrayToList( topLevelTags ) ); + // Find function in component body + var funcDecl = component.body.body.filter( function( item ) { + return ( item.type ?: "" ) == "FunctionDeclaration"; + }); + expect( funcDecl ).toHaveLength( 1, "Component should contain bar function" ); }); }); From 0398dd6b9effc09e6f7b213921704b46601a1281 Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Thu, 18 Dec 2025 16:46:49 +0100 Subject: [PATCH 22/71] LDEV-6006 update tests for script mode astFromString --- test/tickets/LDEV6006.cfc | 42 +++++++++++++++++++++------------------ 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/test/tickets/LDEV6006.cfc b/test/tickets/LDEV6006.cfc index 154bd5eb9ef..1c2ca73d38c 100644 --- a/test/tickets/LDEV6006.cfc +++ b/test/tickets/LDEV6006.cfc @@ -6,22 +6,6 @@ component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { describe( "LDEV-6006: astFromString mishandles CFML comment syntax in string literals", function() { - it( "should parse component with $"; } }'; - var ast = astFromString( code ); + var ast = astFromString( code, "script" ); - // astFromString auto-detects script mode, component is at root + // Component is at root when using script mode expect( ast.body ).toHaveLength( 1, "Should have single element, got #arrayLen( ast.body )# elements" ); expect( ast.body[1].type ).toBe( "CFMLTag", @@ -52,6 +37,25 @@ component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { "String should contain + +component { + function test() { + return "hello"; + } +} + diff --git a/test/tickets/LDEV6007/cfscriptWrapped.cfc b/test/tickets/LDEV6007/cfscriptWrapped.cfc new file mode 100644 index 00000000000..82d00905af3 --- /dev/null +++ b/test/tickets/LDEV6007/cfscriptWrapped.cfc @@ -0,0 +1,7 @@ + +component { + function test() { + return "hello"; + } +} + diff --git a/test/tickets/LDEV6007/luceeTestCase.cfc b/test/tickets/LDEV6007/luceeTestCase.cfc new file mode 100644 index 00000000000..f5c1e164d5a --- /dev/null +++ b/test/tickets/LDEV6007/luceeTestCase.cfc @@ -0,0 +1,11 @@ + +component extends="org.lucee.cfml.test.LuceeTestCase" { + function run( testResults, testBox ) { + describe( "test", function() { + it( "works", function() { + expect( true ).toBe( true ); + }); + }); + } +} + From 3d5394a8786b7dffe88a15be78c93158bc1fb388 Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Thu, 18 Dec 2025 23:47:04 +0100 Subject: [PATCH 28/71] LDEV-5982 snapshot attributes in script mode too The original fix only worked for tag mode. Script mode tags like cfcache( action="flush" ) were still exposing internal _id attributes because the evaluator was called without snapshotting attributes first. --- .../script/AbstrCFMLScriptTransformer.java | 5 ++++ test/tickets/LDEV5982.cfc | 30 +++++++++++++++++++ test/tickets/LDEV5982/cfcache.cfm | 1 + 3 files changed, 36 insertions(+) create mode 100644 test/tickets/LDEV5982/cfcache.cfm diff --git a/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java b/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java index 87f52bffff9..5262706a777 100755 --- a/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java +++ b/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java @@ -71,6 +71,7 @@ import lucee.transformer.bytecode.statement.TagIsland; import lucee.transformer.bytecode.statement.TryCatchFinally; import lucee.transformer.bytecode.statement.While; +import lucee.transformer.bytecode.statement.tag.TagBase; import lucee.transformer.bytecode.statement.tag.TagComponent; import lucee.transformer.bytecode.statement.tag.TagOther; import lucee.transformer.bytecode.statement.tag.TagParam; @@ -2212,6 +2213,10 @@ private boolean isOperator(char c) { private final void eval(TagLibTag tlt, Data data, Tag tag) throws TemplateException { if (tlt.hasTTE()) { + // Snapshot attributes for AST before evaluators modify them + if (data.ast && tag instanceof TagBase) { + ((TagBase) tag).snapshotSourceAttributes(); + } try { tlt.getEvaluator().execute(data.config, tag, tlt, data.flibs, data); } diff --git a/test/tickets/LDEV5982.cfc b/test/tickets/LDEV5982.cfc index f9bcc352ea6..056cd9ff9de 100644 --- a/test/tickets/LDEV5982.cfc +++ b/test/tickets/LDEV5982.cfc @@ -39,6 +39,36 @@ component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { expect( attrNames ).notToInclude( "id", "Internal id attribute should not leak into AST" ); }); + it( "cfcache tag syntax should not have internal _id attribute in AST", function() { + var testDir = getDirectoryFromPath( getCurrentTemplatePath() ) & "LDEV5982/"; + var ast = astFromPath( testDir & "cfcache.cfm" ); + + var cacheTag = findTagByName( ast, "cache" ); + expect( cacheTag ).notToBeNull( "cfcache tag should be present in AST" ); + + var attrs = cacheTag.attributes ?: []; + var attrNames = attrs.map( function( a ) { return a.name; } ); + + // Should have action, but NOT _id + expect( attrNames ).toInclude( "action" ); + expect( attrNames ).notToInclude( "_id", "Internal _id attribute should not leak into AST" ); + }); + + it( "cfcache script syntax should not have internal _id attribute in AST", function() { + var code = 'cfcache( action="flush" );'; + var ast = astFromString( code, "script" ); + + var cacheTag = findTagByName( ast, "cache" ); + expect( cacheTag ).notToBeNull( "cfcache tag should be present in AST" ); + + var attrs = cacheTag.attributes ?: []; + var attrNames = attrs.map( function( a ) { return a.name; } ); + + // Should have action, but NOT _id + expect( attrNames ).toInclude( "action" ); + expect( attrNames ).notToInclude( "_id", "Internal _id attribute should not leak into AST (script mode)" ); + }); + }); } diff --git a/test/tickets/LDEV5982/cfcache.cfm b/test/tickets/LDEV5982/cfcache.cfm new file mode 100644 index 00000000000..ce5a365586f --- /dev/null +++ b/test/tickets/LDEV5982/cfcache.cfm @@ -0,0 +1 @@ + From b72d2e2fc01e17f09a81d51614b71af3cf9426f1 Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Fri, 19 Dec 2025 00:23:50 +0100 Subject: [PATCH 29/71] LDEV-6015 add quoteChar field to AST StringLiteral --- .../bytecode/literal/LitStringImpl.java | 19 ++++++ .../expression/AbstrCFMLExprTransformer.java | 6 ++ .../expression/SimpleExprTransformer.java | 3 +- test/tickets/LDEV6015.cfc | 65 +++++++++++++++++++ test/tickets/LDEV6015/doubleQuote.cfm | 1 + test/tickets/LDEV6015/scriptDouble.cfc | 3 + test/tickets/LDEV6015/scriptSingle.cfc | 3 + test/tickets/LDEV6015/singleQuote.cfm | 1 + 8 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 test/tickets/LDEV6015.cfc create mode 100644 test/tickets/LDEV6015/doubleQuote.cfm create mode 100644 test/tickets/LDEV6015/scriptDouble.cfc create mode 100644 test/tickets/LDEV6015/scriptSingle.cfc create mode 100644 test/tickets/LDEV6015/singleQuote.cfm diff --git a/core/src/main/java/lucee/transformer/bytecode/literal/LitStringImpl.java b/core/src/main/java/lucee/transformer/bytecode/literal/LitStringImpl.java index c800bc15faa..a863c9b230d 100644 --- a/core/src/main/java/lucee/transformer/bytecode/literal/LitStringImpl.java +++ b/core/src/main/java/lucee/transformer/bytecode/literal/LitStringImpl.java @@ -52,6 +52,7 @@ public class LitStringImpl extends ExpressionBase implements LitString, ExprStri private String str; private String rawSource; // Original source representation for AST dump private boolean fromBracket; + private char quoteChar; // Original quote character (' or ") for AST dump /* * public static ExprString toExprString(String str, Position start,Position end) { return new @@ -211,6 +212,20 @@ public String getRawSource() { return rawSource; } + /** + * Set the original quote character for AST dump. + */ + public void setQuoteChar(char quoteChar) { + this.quoteChar = quoteChar; + } + + /** + * Get the original quote character. + */ + public char getQuoteChar() { + return quoteChar; + } + @Override public void dump(Struct sct) { super.dump(sct); @@ -228,5 +243,9 @@ else if (str != null) { else { sct.setEL(KeyConstants._raw, "null"); } + // Only include quoteChar if it was set (AST mode) + if (quoteChar != 0) { + sct.setEL("quoteChar", String.valueOf(quoteChar)); + } } } \ No newline at end of file diff --git a/core/src/main/java/lucee/transformer/cfml/expression/AbstrCFMLExprTransformer.java b/core/src/main/java/lucee/transformer/cfml/expression/AbstrCFMLExprTransformer.java index f5b0a5fc035..c59909740fd 100755 --- a/core/src/main/java/lucee/transformer/cfml/expression/AbstrCFMLExprTransformer.java +++ b/core/src/main/java/lucee/transformer/cfml/expression/AbstrCFMLExprTransformer.java @@ -48,6 +48,7 @@ import lucee.transformer.bytecode.expression.var.NamedArgumentImpl; import lucee.transformer.bytecode.expression.var.UDF; import lucee.transformer.bytecode.literal.Identifier; +import lucee.transformer.bytecode.literal.LitStringImpl; import lucee.transformer.bytecode.literal.Null; import lucee.transformer.bytecode.literal.NullConstant; import lucee.transformer.bytecode.op.OpVariable; @@ -1158,6 +1159,11 @@ else if (str.length() != 0) { var.fromHash(true); } + // Set quoteChar to preserve original quote style for AST dump + if (expr instanceof LitStringImpl) { + ((LitStringImpl) expr).setQuoteChar(quoter); + } + return expr; } diff --git a/core/src/main/java/lucee/transformer/cfml/expression/SimpleExprTransformer.java b/core/src/main/java/lucee/transformer/cfml/expression/SimpleExprTransformer.java index 31252af3d2c..c0f280265c4 100755 --- a/core/src/main/java/lucee/transformer/cfml/expression/SimpleExprTransformer.java +++ b/core/src/main/java/lucee/transformer/cfml/expression/SimpleExprTransformer.java @@ -104,9 +104,10 @@ else if (!insideSpecial && cfml.isCurrent(quoter)) { rawSource.append(quoter); // End with closing quote LitString rtn = f.createLitString(str.toString(), line, cfml.getPosition()); - // Set raw source to preserve original representation for AST dump + // Set raw source and quoteChar to preserve original representation for AST dump if (rtn instanceof LitStringImpl) { ((LitStringImpl) rtn).setRawSource(rawSource.toString()); + ((LitStringImpl) rtn).setQuoteChar(quoter); } cfml.removeSpace(); return rtn; diff --git a/test/tickets/LDEV6015.cfc b/test/tickets/LDEV6015.cfc new file mode 100644 index 00000000000..eadc636a2d0 --- /dev/null +++ b/test/tickets/LDEV6015.cfc @@ -0,0 +1,65 @@ +component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { + + variables.testDir = getDirectoryFromPath( getCurrentTemplatePath() ) & "LDEV6015/"; + + function run( testResults, testBox ) { + + describe( "LDEV-6015: AST StringLiteral should include quoteChar", function() { + + it( "should preserve double quote in script", function() { + var ast = astFromPath( variables.testDir & "scriptDouble.cfc" ); + // Component body -> first statement is assignment + var comp = ast.body[1]; + var assignment = comp.body.body[1]; + var strLiteral = assignment.right; + + expect( strLiteral.type ).toBe( "StringLiteral" ); + expect( strLiteral.value ).toBe( "hello" ); + expect( strLiteral ).toHaveKey( "quoteChar", "StringLiteral should have quoteChar field" ); + expect( strLiteral.quoteChar ).toBe( '"', "Should preserve double quote" ); + }); + + it( "should preserve single quote in script", function() { + var ast = astFromPath( variables.testDir & "scriptSingle.cfc" ); + var comp = ast.body[1]; + var assignment = comp.body.body[1]; + var strLiteral = assignment.right; + + expect( strLiteral.type ).toBe( "StringLiteral" ); + expect( strLiteral.value ).toBe( "hello" ); + expect( strLiteral ).toHaveKey( "quoteChar", "StringLiteral should have quoteChar field" ); + expect( strLiteral.quoteChar ).toBe( "'", "Should preserve single quote" ); + }); + + it( "should preserve double quote in tag attribute", function() { + var ast = astFromPath( variables.testDir & "doubleQuote.cfm" ); + var setTag = ast.body[1]; + expect( setTag.type ).toBe( "CFMLTag" ); + expect( setTag.name ).toBe( "set" ); + // cfset has "noname" attribute with AssignmentExpression value + var assignment = setTag.attributes[1].value; + expect( assignment.type ).toBe( "AssignmentExpression" ); + var strLiteral = assignment.right; + expect( strLiteral.type ).toBe( "StringLiteral" ); + expect( strLiteral ).toHaveKey( "quoteChar", "StringLiteral should have quoteChar field" ); + expect( strLiteral.quoteChar ).toBe( '"' ); + }); + + it( "should preserve single quote in tag attribute", function() { + var ast = astFromPath( variables.testDir & "singleQuote.cfm" ); + var setTag = ast.body[1]; + expect( setTag.type ).toBe( "CFMLTag" ); + expect( setTag.name ).toBe( "set" ); + // cfset has "noname" attribute with AssignmentExpression value + var assignment = setTag.attributes[1].value; + expect( assignment.type ).toBe( "AssignmentExpression" ); + var strLiteral = assignment.right; + expect( strLiteral.type ).toBe( "StringLiteral" ); + expect( strLiteral ).toHaveKey( "quoteChar", "StringLiteral should have quoteChar field" ); + expect( strLiteral.quoteChar ).toBe( "'" ); + }); + + }); + } + +} diff --git a/test/tickets/LDEV6015/doubleQuote.cfm b/test/tickets/LDEV6015/doubleQuote.cfm new file mode 100644 index 00000000000..728fba1e6c5 --- /dev/null +++ b/test/tickets/LDEV6015/doubleQuote.cfm @@ -0,0 +1 @@ + diff --git a/test/tickets/LDEV6015/scriptDouble.cfc b/test/tickets/LDEV6015/scriptDouble.cfc new file mode 100644 index 00000000000..9d6a31af566 --- /dev/null +++ b/test/tickets/LDEV6015/scriptDouble.cfc @@ -0,0 +1,3 @@ +component { + x = "hello"; +} diff --git a/test/tickets/LDEV6015/scriptSingle.cfc b/test/tickets/LDEV6015/scriptSingle.cfc new file mode 100644 index 00000000000..556f4d513b8 --- /dev/null +++ b/test/tickets/LDEV6015/scriptSingle.cfc @@ -0,0 +1,3 @@ +component { + x = 'hello'; +} diff --git a/test/tickets/LDEV6015/singleQuote.cfm b/test/tickets/LDEV6015/singleQuote.cfm new file mode 100644 index 00000000000..c977561db77 --- /dev/null +++ b/test/tickets/LDEV6015/singleQuote.cfm @@ -0,0 +1 @@ + From bd018fae34515c29c32d8847e5baf5efed780924 Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Fri, 19 Dec 2025 00:51:58 +0100 Subject: [PATCH 30/71] LDEV-6016 add static and final markers to AST output --- .../transformer/bytecode/StaticBody.java | 8 ++++ .../bytecode/expression/var/Assign.java | 4 ++ test/tickets/LDEV6016.cfc | 39 +++++++++++++++++++ test/tickets/LDEV6016/staticBlock.cfc | 5 +++ 4 files changed, 56 insertions(+) create mode 100644 test/tickets/LDEV6016.cfc create mode 100644 test/tickets/LDEV6016/staticBlock.cfc diff --git a/core/src/main/java/lucee/transformer/bytecode/StaticBody.java b/core/src/main/java/lucee/transformer/bytecode/StaticBody.java index c2a4657e7e1..f04d1526bf2 100644 --- a/core/src/main/java/lucee/transformer/bytecode/StaticBody.java +++ b/core/src/main/java/lucee/transformer/bytecode/StaticBody.java @@ -17,6 +17,7 @@ */ package lucee.transformer.bytecode; +import lucee.runtime.type.Struct; import lucee.transformer.Factory; public final class StaticBody extends BodyBase { @@ -25,4 +26,11 @@ public StaticBody(Factory f) { super(f); } + @Override + public void dump(Struct sct) { + super.dump(sct); + // Mark this block as a static initializer + sct.setEL("static", Boolean.TRUE); + } + } \ No newline at end of file diff --git a/core/src/main/java/lucee/transformer/bytecode/expression/var/Assign.java b/core/src/main/java/lucee/transformer/bytecode/expression/var/Assign.java index a3cdfcd0aed..bd43b309adb 100755 --- a/core/src/main/java/lucee/transformer/bytecode/expression/var/Assign.java +++ b/core/src/main/java/lucee/transformer/bytecode/expression/var/Assign.java @@ -353,5 +353,9 @@ public void dump(Struct sct) { sct.setEL(KeyConstants._right, right); value.dump(right); + // Include final modifier if set + if (modifier == lucee.runtime.component.Member.MODIFIER_FINAL) { + sct.setEL(KeyConstants._final, Boolean.TRUE); + } } } \ No newline at end of file diff --git a/test/tickets/LDEV6016.cfc b/test/tickets/LDEV6016.cfc new file mode 100644 index 00000000000..ce23b9996c0 --- /dev/null +++ b/test/tickets/LDEV6016.cfc @@ -0,0 +1,39 @@ +component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { + + variables.testDir = getDirectoryFromPath( getCurrentTemplatePath() ) & "LDEV6016/"; + + function run( testResults, testBox ) { + + describe( "LDEV-6016: AST static blocks should have static marker", function() { + + it( "should mark static block with static=true", function() { + var ast = astFromPath( variables.testDir & "staticBlock.cfc" ); + var comp = ast.body[1]; + // Find the static block in the component body + var staticBlock = comp.body.body.filter( function( s ) { + return ( s.type == "BlockStatement" || s.type == "CFMLTag" ) && structKeyExists( s, "static" ); + }); + expect( staticBlock ).toHaveLength( 1, "Should have one static block" ); + expect( staticBlock[1].static ).toBe( true, "Static block should have static=true" ); + }); + + it( "should preserve final keyword on assignment inside static block", function() { + var ast = astFromPath( variables.testDir & "staticBlock.cfc" ); + var comp = ast.body[1]; + // Find static block + var staticBlocks = comp.body.body.filter( function( s ) { + return structKeyExists( s, "static" ) && s.static == true; + }); + expect( staticBlocks ).toHaveLength( 1, "Should have one static block" ); + // Find the assignment inside + var body = staticBlocks[1].body; + var assignment = body[1]; + expect( assignment.type ).toBe( "AssignmentExpression" ); + expect( assignment ).toHaveKey( "final", "Assignment should have final marker" ); + expect( assignment.final ).toBe( true, "Assignment should be marked final" ); + }); + + }); + } + +} diff --git a/test/tickets/LDEV6016/staticBlock.cfc b/test/tickets/LDEV6016/staticBlock.cfc new file mode 100644 index 00000000000..4de179e3936 --- /dev/null +++ b/test/tickets/LDEV6016/staticBlock.cfc @@ -0,0 +1,5 @@ +component { + static { + final RED = "red"; + } +} From 46ef9864aa1017273737dae431e5c58cc73a0818 Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Fri, 19 Dec 2025 01:44:15 +0100 Subject: [PATCH 31/71] LDEV-5990 add hintSource field to AST FunctionDeclaration Add hintSource field to indicate whether function hint came from a docblock comment or a hint attribute. --- .../bytecode/statement/udf/Function.java | 11 +++++++- test/tickets/LDEV5990.cfc | 28 +++++++++++++++++++ test/tickets/LDEV5990/hintedParams.cfc | 11 ++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/core/src/main/java/lucee/transformer/bytecode/statement/udf/Function.java b/core/src/main/java/lucee/transformer/bytecode/statement/udf/Function.java index 62de87dee04..b2053eae809 100644 --- a/core/src/main/java/lucee/transformer/bytecode/statement/udf/Function.java +++ b/core/src/main/java/lucee/transformer/bytecode/statement/udf/Function.java @@ -141,6 +141,7 @@ public abstract class Function extends StatementBaseNoFinal implements Opcodes, int access = Component.ACCESS_PUBLIC; ExprString displayName; ExprString hint; + String hintSource; // "docblock" or "attribute" - indicates where hint came from Body body; List arguments = new ArrayList(); Map metadata; @@ -534,6 +535,7 @@ public final void setMetaData(Map metadata) { public final void setHint(Factory factory, String hint) { this.hint = factory.createLitString(hint); + this.hintSource = "docblock"; } public final void addAttribute(BytecodeContext bc, Attribute attr) throws TemplateException { @@ -558,7 +560,10 @@ else if ("access".equals(name)) { else if ("output".equals(name)) this.output = toLitBoolean(bc, name, attr.getValue()); else if ("bufferoutput".equals(name)) this.bufferOutput = toLitBoolean(bc, name, attr.getValue()); else if ("displayname".equals(name)) this.displayName = toLitString(bc, name, attr.getValue()); - else if ("hint".equals(name)) this.hint = toLitString(bc, name, attr.getValue()); + else if ("hint".equals(name)) { + this.hint = toLitString(bc, name, attr.getValue()); + this.hintSource = "attribute"; + } else if ("description".equals(name)) this.description = toLitString(bc, name, attr.getValue()); else if ("returnformat".equals(name)) this.returnFormat = toLitString(bc, name, attr.getValue()); else if ("securejson".equals(name)) this.secureJson = toLitBoolean(bc, name, attr.getValue()); @@ -693,6 +698,10 @@ public void dump(Struct sct, String type) { hint.dump(s); sct.setEL(KeyConstants._hint, s); } + // hintSource - indicates where hint came from (docblock or attribute) + if (hintSource != null) { + sct.setEL("hintSource", hintSource); + } // secureJson if (secureJson != null) { Struct s = new StructImpl(Struct.TYPE_LINKED); diff --git a/test/tickets/LDEV5990.cfc b/test/tickets/LDEV5990.cfc index d5c0f053652..6e3723ff251 100644 --- a/test/tickets/LDEV5990.cfc +++ b/test/tickets/LDEV5990.cfc @@ -55,6 +55,34 @@ component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { }); + describe( "LDEV-5990: Docblock hints should include source annotation", function() { + + it( "should indicate hint came from docblock vs attribute", function() { + var ast = astFromPath( variables.testDir & "hintedParams.cfc" ); + + // Find the withDocblock function (has hint from docblock) + var func = findFunction( ast, "withDocblock" ); + expect( func ).notToBeNull( "withDocblock function should be found in AST" ); + expect( func ).toHaveKey( "hint" ); + // Should have some way to know this hint came from a docblock + expect( func ).toHaveKey( "hintSource", "AST should indicate hint source" ); + expect( func.hintSource ).toBe( "docblock" ); + }); + + it( "should indicate hint came from attribute when using hint attribute", function() { + var ast = astFromPath( variables.testDir & "hintedParams.cfc" ); + + // Find the withHintAttr function (has hint="..." attribute) + var func = findFunction( ast, "withHintAttr" ); + expect( func ).notToBeNull( "withHintAttr function should be found in AST" ); + expect( func ).toHaveKey( "hint" ); + // Should indicate this hint came from an attribute + expect( func ).toHaveKey( "hintSource", "AST should indicate hint source" ); + expect( func.hintSource ).toBe( "attribute" ); + }); + + }); + } /** diff --git a/test/tickets/LDEV5990/hintedParams.cfc b/test/tickets/LDEV5990/hintedParams.cfc index c0fd74a575b..8fce8c5d16c 100644 --- a/test/tickets/LDEV5990/hintedParams.cfc +++ b/test/tickets/LDEV5990/hintedParams.cfc @@ -18,4 +18,15 @@ component { return message; } + /** + * This hint comes from a docblock + */ + function withDocblock() { + return "docblock"; + } + + function withHintAttr() hint="This hint comes from an attribute" { + return "attribute"; + } + } From 8bade21eecf78b63e1ec04b85cb233ea7c87da86 Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Fri, 19 Dec 2025 01:58:31 +0100 Subject: [PATCH 32/71] LDEV-6019 allow unquoted function attributes in AST mode Handle unquoted attribute values like access=remote and output=false by converting Variable identifiers to literal strings/booleans. --- .../bytecode/statement/udf/Function.java | 19 ++++++++- test/tickets/LDEV6019.cfc | 41 +++++++++++++++++++ test/tickets/LDEV6019/colonAccess.cfc | 7 ++++ test/tickets/LDEV6019/unquotedAccess.cfc | 7 ++++ test/tickets/LDEV6019/unquotedBoolean.cfc | 7 ++++ 5 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 test/tickets/LDEV6019.cfc create mode 100644 test/tickets/LDEV6019/colonAccess.cfc create mode 100644 test/tickets/LDEV6019/unquotedAccess.cfc create mode 100644 test/tickets/LDEV6019/unquotedBoolean.cfc diff --git a/core/src/main/java/lucee/transformer/bytecode/statement/udf/Function.java b/core/src/main/java/lucee/transformer/bytecode/statement/udf/Function.java index b2053eae809..b68cf7a5773 100644 --- a/core/src/main/java/lucee/transformer/bytecode/statement/udf/Function.java +++ b/core/src/main/java/lucee/transformer/bytecode/statement/udf/Function.java @@ -606,13 +606,28 @@ else if ("modifier".equals(name)) { private final LitString toLitString(BytecodeContext bc, String name, Expression value) throws TransformerException { ExprString es = value.getFactory().toExprString(value); - if (!(es instanceof LitString)) throw new TransformerException(bc, "Value of attribute [" + name + "] must have a literal/constant value", getStart()); + if (!(es instanceof LitString)) { + // Handle unquoted identifiers like access=remote - convert Variable to LitString + String str = ASMUtil.toString(bc, value, null); + if (str != null) { + return value.getFactory().createLitString(str); + } + throw new TransformerException(bc, "Value of attribute [" + name + "] must have a literal/constant value", getStart()); + } return (LitString) es; } private final LitBoolean toLitBoolean(BytecodeContext bc, String name, Expression value) throws TransformerException { ExprBoolean eb = value.getFactory().toExprBoolean(value); - if (!(eb instanceof LitBoolean)) throw new TransformerException(bc, "Value of attribute [" + name + "] must have a literal/constant value", getStart()); + if (!(eb instanceof LitBoolean)) { + // Handle unquoted identifiers like output=false - convert Variable to LitBoolean + String str = ASMUtil.toString(bc, value, null); + if (str != null) { + if ("true".equalsIgnoreCase(str)) return value.getFactory().TRUE(); + if ("false".equalsIgnoreCase(str)) return value.getFactory().FALSE(); + } + throw new TransformerException(bc, "Value of attribute [" + name + "] must have a literal/constant value", getStart()); + } return (LitBoolean) eb; } diff --git a/test/tickets/LDEV6019.cfc b/test/tickets/LDEV6019.cfc new file mode 100644 index 00000000000..c6bd7d72ea7 --- /dev/null +++ b/test/tickets/LDEV6019.cfc @@ -0,0 +1,41 @@ +component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { + + variables.testDir = getDirectoryFromPath( getCurrentTemplatePath() ) & "LDEV6019/"; + + function run( testResults, testBox ) { + + describe( "LDEV-6019: Unquoted function attributes after parenthesis should parse", function() { + + it( "should parse function with unquoted access=remote attribute", function() { + // This should not throw an error + var ast = astFromPath( variables.testDir & "unquotedAccess.cfc" ); + + expect( ast.type ).toBe( "Program" ); + // Find the function and verify it has access attribute + var func = ast.body[ 1 ].body.body[ 1 ]; + expect( func.type ).toBe( "FunctionDeclaration" ); + expect( func.access ).toBe( "remote" ); + }); + + it( "should parse function with colon syntax access:remote attribute", function() { + var ast = astFromPath( variables.testDir & "colonAccess.cfc" ); + + expect( ast.type ).toBe( "Program" ); + var func = ast.body[ 1 ].body.body[ 1 ]; + expect( func.type ).toBe( "FunctionDeclaration" ); + expect( func.access ).toBe( "remote" ); + }); + + it( "should parse function with unquoted boolean attribute", function() { + var ast = astFromPath( variables.testDir & "unquotedBoolean.cfc" ); + + expect( ast.type ).toBe( "Program" ); + var func = ast.body[ 1 ].body.body[ 1 ]; + expect( func.type ).toBe( "FunctionDeclaration" ); + }); + + }); + + } + +} diff --git a/test/tickets/LDEV6019/colonAccess.cfc b/test/tickets/LDEV6019/colonAccess.cfc new file mode 100644 index 00000000000..68f3bc6801e --- /dev/null +++ b/test/tickets/LDEV6019/colonAccess.cfc @@ -0,0 +1,7 @@ +component { + + function get( string x ) access:remote { + return "true"; + } + +} diff --git a/test/tickets/LDEV6019/unquotedAccess.cfc b/test/tickets/LDEV6019/unquotedAccess.cfc new file mode 100644 index 00000000000..3b5c43910c4 --- /dev/null +++ b/test/tickets/LDEV6019/unquotedAccess.cfc @@ -0,0 +1,7 @@ +component { + + function get( string x ) access=remote { + return "true"; + } + +} diff --git a/test/tickets/LDEV6019/unquotedBoolean.cfc b/test/tickets/LDEV6019/unquotedBoolean.cfc new file mode 100644 index 00000000000..df7378708cc --- /dev/null +++ b/test/tickets/LDEV6019/unquotedBoolean.cfc @@ -0,0 +1,7 @@ +component { + + function getData() secured=false { + return "data"; + } + +} From a2308096426b2b1862aed5c6154e30a5bf677842 Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Fri, 19 Dec 2025 02:00:14 +0100 Subject: [PATCH 33/71] LDEV-6009 fix sourceType for script components with tag islands Script components containing tag islands were returning sourceType=tag instead of sourceType=script. Fixed by using containsComponentRecursive() to detect components inside cfscript in AST mode, since isPage() returns true when component isn't moved to root. Also preserve wrappedInScript flag in SourceCode.subCFMLString(). --- .../lucee/transformer/cfml/tag/CFMLTransformer.java | 4 +++- .../main/java/lucee/transformer/util/SourceCode.java | 6 ++++-- test/tickets/LDEV6009.cfc | 10 ++++++++++ test/tickets/LDEV6009/tagIsland.cfc | 8 ++++++++ 4 files changed, 25 insertions(+), 3 deletions(-) create mode 100644 test/tickets/LDEV6009/tagIsland.cfc diff --git a/core/src/main/java/lucee/transformer/cfml/tag/CFMLTransformer.java b/core/src/main/java/lucee/transformer/cfml/tag/CFMLTransformer.java index daa0b71dc8c..03eb6b9a3cd 100755 --- a/core/src/main/java/lucee/transformer/cfml/tag/CFMLTransformer.java +++ b/core/src/main/java/lucee/transformer/cfml/tag/CFMLTransformer.java @@ -277,7 +277,9 @@ else if (isCFMLCompExt) { throw e.getTemplateException(); } // we only use that result if it is a component now - if (_p != null && !_p.isPage()) return _p; + // In AST mode, isPage() returns true even when component exists inside cfscript + // because the component isn't moved to root. Use recursive check instead. + if (_p != null && (!_p.isPage() || containsComponentRecursive(_p))) return _p; } // In AST mode, component stays inside cfscript so skip this validation diff --git a/core/src/main/java/lucee/transformer/util/SourceCode.java b/core/src/main/java/lucee/transformer/util/SourceCode.java index f366f98c3fb..5739f7927a8 100755 --- a/core/src/main/java/lucee/transformer/util/SourceCode.java +++ b/core/src/main/java/lucee/transformer/util/SourceCode.java @@ -722,8 +722,10 @@ public SourceCode subCFMLString(int start) { * @return subset of the SourceCode as new SourcCode */ public SourceCode subCFMLString(int start, int count) { - return new SourceCode(this, String.valueOf(text, start, count), writeLog); - + SourceCode sub = new SourceCode(this, String.valueOf(text, start, count), writeLog); + // Preserve wrappedInScript flag from parent so AST generation knows the original source type + sub.setWrappedInScript(this.wrappedInScript); + return sub; } /** diff --git a/test/tickets/LDEV6009.cfc b/test/tickets/LDEV6009.cfc index 528a8fcc147..1019fdbc3d0 100644 --- a/test/tickets/LDEV6009.cfc +++ b/test/tickets/LDEV6009.cfc @@ -50,6 +50,16 @@ component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { expect( ast ).toHaveKey( "handleUnquotedAttrValueAsString" ); }); + it( "should return sourceType='script' for script component with tag islands", function() { + var testDir = getDirectoryFromPath( getCurrentTemplatePath() ) & "LDEV6009/"; + var ast = astFromPath( testDir & "tagIsland.cfc" ); + + expect( ast.type ).toBe( "Program" ); + expect( ast ).toHaveKey( "sourceType" ); + // Script component should be "script" even if it contains tag islands + expect( ast.sourceType ).toBe( "script" ); + }); + }); } diff --git a/test/tickets/LDEV6009/tagIsland.cfc b/test/tickets/LDEV6009/tagIsland.cfc new file mode 100644 index 00000000000..2a779160206 --- /dev/null +++ b/test/tickets/LDEV6009/tagIsland.cfc @@ -0,0 +1,8 @@ +component { + function test() { + ``` + + ``` + return x; + } +} From 473ddf915eed2ad1624cee167731154a126ae8a8 Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Fri, 19 Dec 2025 02:57:30 +0100 Subject: [PATCH 34/71] LDEV-6017 AST interpolated strings use TemplateLiteral instead of BinaryExpression CONCAT --- .../main/java/lucee/transformer/Factory.java | 2 + .../transformer/bytecode/BytecodeFactory.java | 5 + .../transformer/bytecode/op/OpString.java | 122 ++++++++++++++++-- .../expression/AbstrCFMLExprTransformer.java | 6 +- .../interpreter/InterpreterFactory.java | 5 + .../transformer/interpreter/op/OpString.java | 8 ++ test/tickets/LDEV6017.cfc | 68 ++++++++++ 7 files changed, 201 insertions(+), 15 deletions(-) create mode 100644 test/tickets/LDEV6017.cfc diff --git a/core/src/main/java/lucee/transformer/Factory.java b/core/src/main/java/lucee/transformer/Factory.java index f95671df5b5..b559d4a6102 100644 --- a/core/src/main/java/lucee/transformer/Factory.java +++ b/core/src/main/java/lucee/transformer/Factory.java @@ -160,6 +160,8 @@ public abstract class Factory { public abstract ExprString opString(Expression left, Expression right, boolean concatStatic); + public abstract ExprString opStringInterpolation(Expression left, Expression right); + public abstract ExprBoolean opBool(Expression left, Expression right, int operation); public abstract ExprNumber opNumber(Expression left, Expression right, int operation); diff --git a/core/src/main/java/lucee/transformer/bytecode/BytecodeFactory.java b/core/src/main/java/lucee/transformer/bytecode/BytecodeFactory.java index 9a417e2f431..b3a47a8d99c 100644 --- a/core/src/main/java/lucee/transformer/bytecode/BytecodeFactory.java +++ b/core/src/main/java/lucee/transformer/bytecode/BytecodeFactory.java @@ -296,6 +296,11 @@ public ExprString opString(Expression left, Expression right, boolean concatStat return OpString.toExprString(left, right, concatStatic); } + @Override + public ExprString opStringInterpolation(Expression left, Expression right) { + return OpString.toExprStringInterpolation(left, right); + } + @Override public ExprBoolean opBool(Expression left, Expression right, int operation) { return OpBool.toExprBoolean(left, right, operation); diff --git a/core/src/main/java/lucee/transformer/bytecode/op/OpString.java b/core/src/main/java/lucee/transformer/bytecode/op/OpString.java index a7dc484e38f..6edcdc74b1f 100755 --- a/core/src/main/java/lucee/transformer/bytecode/op/OpString.java +++ b/core/src/main/java/lucee/transformer/bytecode/op/OpString.java @@ -21,6 +21,8 @@ import org.objectweb.asm.Type; import org.objectweb.asm.commons.Method; +import lucee.runtime.type.Array; +import lucee.runtime.type.ArrayImpl; import lucee.runtime.type.Struct; import lucee.runtime.type.StructImpl; import lucee.runtime.type.util.KeyConstants; @@ -28,6 +30,7 @@ import lucee.transformer.bytecode.BytecodeContext; import lucee.transformer.bytecode.expression.ExpressionBase; import lucee.transformer.bytecode.util.Types; +import lucee.transformer.cast.Cast; import lucee.transformer.expression.ExprString; import lucee.transformer.expression.Expression; import lucee.transformer.expression.literal.Literal; @@ -36,6 +39,7 @@ public final class OpString extends ExpressionBase implements ExprString { private ExprString right; private ExprString left; + private boolean fromInterpolation = false; // String concat (String) private final static Method METHOD_CONCAT = new Method("concat", Types.STRING, new Type[] { Types.STRING }); @@ -47,6 +51,14 @@ private OpString(Expression left, Expression right) { this.right = left.getFactory().toExprString(right); } + public void setFromInterpolation(boolean fromInterpolation) { + this.fromInterpolation = fromInterpolation; + } + + public boolean isFromInterpolation() { + return fromInterpolation; + } + public static ExprString toExprString(Expression left, Expression right, boolean concatStatic) { if (concatStatic && left instanceof Literal && right instanceof Literal) { String l = ((Literal) left).getString(); @@ -56,6 +68,26 @@ public static ExprString toExprString(Expression left, Expression right, boolean return new OpString(left, right); } + /** + * Create an OpString that represents string interpolation (e.g., "foo.#bar#.baz") + * rather than explicit concatenation (e.g., foo & bar). + * The result will be dumped as TemplateLiteral/InterpolatedString in the AST. + */ + public static ExprString toExprStringInterpolation(Expression left, Expression right) { + // For interpolated strings, we don't want to merge literals at parse time + // because we want to preserve the original structure for AST output + ExprString result = toExprString(left, right, false); + if (result instanceof OpString) { + OpString opStr = (OpString) result; + opStr.setFromInterpolation(true); + // Also propagate interpolation flag from left operand if it's an OpString + if (opStr.left instanceof OpString && ((OpString) opStr.left).isFromInterpolation()) { + // Already set on this one, nothing more needed + } + } + return result; + } + @Override public Type _writeOut(BytecodeContext bc, int mode) throws TransformerException { left.writeOut(bc, MODE_REF); @@ -67,19 +99,85 @@ public Type _writeOut(BytecodeContext bc, int mode) throws TransformerException @Override public void dump(Struct sct) { super.dump(sct); - sct.setEL(KeyConstants._type, "BinaryExpression"); - sct.setEL(KeyConstants._operator, "CONCAT"); - // left - { - Struct sctLeft = new StructImpl(Struct.TYPE_LINKED); - left.dump(sctLeft); - sct.setEL(KeyConstants._left, sctLeft); + + if (fromInterpolation) { + // Output as TemplateLiteral (like JS template literals) + // Collect all parts: quasis (string literals) and expressions + java.util.List quasis = new java.util.ArrayList<>(); + java.util.List expressions = new java.util.ArrayList<>(); + collectInterpolationParts(this, quasis, expressions); + + sct.setEL(KeyConstants._type, "TemplateLiteral"); + + // Add quasis array + Array quasisArr = new ArrayImpl(); + for (Expression q : quasis) { + Struct qNode = new StructImpl(Struct.TYPE_LINKED); + q.dump(qNode); + quasisArr.appendEL(qNode); + } + sct.setEL("quasis", quasisArr); + + // Add expressions array + Array exprsArr = new ArrayImpl(); + for (Expression e : expressions) { + Struct eNode = new StructImpl(Struct.TYPE_LINKED); + Expression expr = (e instanceof Cast) ? ((Cast) e).getExpr() : e; + expr.dump(eNode); + exprsArr.appendEL(eNode); + } + sct.setEL("expressions", exprsArr); + } + else { + sct.setEL(KeyConstants._type, "BinaryExpression"); + sct.setEL(KeyConstants._operator, "CONCAT"); + // left - unwrap CastString wrapper for cleaner AST output + { + Struct sctLeft = new StructImpl(Struct.TYPE_LINKED); + Expression leftExpr = (left instanceof Cast) ? ((Cast) left).getExpr() : left; + leftExpr.dump(sctLeft); + sct.setEL(KeyConstants._left, sctLeft); + } + // right - unwrap CastString wrapper for cleaner AST output + { + Struct sctRight = new StructImpl(Struct.TYPE_LINKED); + Expression rightExpr = (right instanceof Cast) ? ((Cast) right).getExpr() : right; + rightExpr.dump(sctRight); + sct.setEL(KeyConstants._right, sctRight); + } + } + } + + /** + * Recursively collect quasis (string literals) and expressions from an interpolation chain. + * Traverses left-to-right, building parallel arrays of quasis and expressions. + */ + private static void collectInterpolationParts(Expression expr, java.util.List quasis, java.util.List expressions) { + Expression unwrapped = (expr instanceof Cast) ? ((Cast) expr).getExpr() : expr; + + if (unwrapped instanceof OpString) { + OpString op = (OpString) unwrapped; + if (op.fromInterpolation) { + // Recursively process left side + collectInterpolationParts(op.left, quasis, expressions); + // Right side is either literal or expression + Expression rightUnwrapped = (op.right instanceof Cast) ? ((Cast) op.right).getExpr() : op.right; + if (rightUnwrapped instanceof Literal) { + quasis.add(rightUnwrapped); + } + else { + expressions.add(rightUnwrapped); + } + return; + } + } + + // Base case: not an OpString from interpolation + if (unwrapped instanceof Literal) { + quasis.add(unwrapped); } - // right - { - Struct sctRight = new StructImpl(Struct.TYPE_LINKED); - right.dump(sctRight); - sct.setEL(KeyConstants._right, sctRight); + else { + expressions.add(unwrapped); } } } \ No newline at end of file diff --git a/core/src/main/java/lucee/transformer/cfml/expression/AbstrCFMLExprTransformer.java b/core/src/main/java/lucee/transformer/cfml/expression/AbstrCFMLExprTransformer.java index c59909740fd..e9aa46a5618 100755 --- a/core/src/main/java/lucee/transformer/cfml/expression/AbstrCFMLExprTransformer.java +++ b/core/src/main/java/lucee/transformer/cfml/expression/AbstrCFMLExprTransformer.java @@ -1116,7 +1116,7 @@ protected Expression string(Data data) throws TemplateException { if (str.length() != 0) { exprStr = data.factory.createLitString(str.toString(), line, data.srcCode.getPosition()); if (expr != null) { - expr = data.factory.opString(expr, exprStr); + expr = data.factory.opStringInterpolation(expr, exprStr); } else expr = exprStr; str = new StringBuilder(); @@ -1125,7 +1125,7 @@ protected Expression string(Data data) throws TemplateException { expr = inner; } else { - expr = data.factory.opString(expr, inner); + expr = data.factory.opStringInterpolation(expr, inner); } } } @@ -1150,7 +1150,7 @@ else if (data.srcCode.isCurrent(quoter)) { if (expr == null) expr = data.factory.createLitString(str.toString(), line, data.srcCode.getPosition()); else if (str.length() != 0) { - expr = data.factory.opString(expr, data.factory.createLitString(str.toString(), line, data.srcCode.getPosition())); + expr = data.factory.opStringInterpolation(expr, data.factory.createLitString(str.toString(), line, data.srcCode.getPosition())); } comments(data); diff --git a/core/src/main/java/lucee/transformer/interpreter/InterpreterFactory.java b/core/src/main/java/lucee/transformer/interpreter/InterpreterFactory.java index 08695d0a720..5fddd8d1492 100644 --- a/core/src/main/java/lucee/transformer/interpreter/InterpreterFactory.java +++ b/core/src/main/java/lucee/transformer/interpreter/InterpreterFactory.java @@ -266,6 +266,11 @@ public ExprString opString(Expression left, Expression right, boolean concatStat return OpString.toExprString(left, right, concatStatic); } + @Override + public ExprString opStringInterpolation(Expression left, Expression right) { + return OpString.toExprStringInterpolation(left, right); + } + @Override public ExprBoolean opBool(Expression left, Expression right, int operation) { return OpBool.toExprBoolean(left, right, operation); diff --git a/core/src/main/java/lucee/transformer/interpreter/op/OpString.java b/core/src/main/java/lucee/transformer/interpreter/op/OpString.java index c73c6e70394..43fe916766d 100644 --- a/core/src/main/java/lucee/transformer/interpreter/op/OpString.java +++ b/core/src/main/java/lucee/transformer/interpreter/op/OpString.java @@ -29,6 +29,14 @@ public static ExprString toExprString(Expression left, Expression right, boolean return new OpString(left, right); } + /** + * For interpreter, interpolation is handled the same as regular concat. + * The distinction only matters for AST dump output in bytecode version. + */ + public static ExprString toExprStringInterpolation(Expression left, Expression right) { + return toExprString(left, right, false); + } + @Override public Class _writeOut(InterpreterContext ic, int mode) throws PageException { ic.stack(ic.getValueAsString(left).concat(ic.getValueAsString(right))); diff --git a/test/tickets/LDEV6017.cfc b/test/tickets/LDEV6017.cfc new file mode 100644 index 00000000000..572fc04817f --- /dev/null +++ b/test/tickets/LDEV6017.cfc @@ -0,0 +1,68 @@ +component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { + + function run( testResults, testBox ) { + + describe( "LDEV-6017: Interpolated struct keys should be TemplateLiteral not BinaryExpression", function() { + + it( "should represent interpolated struct key as TemplateLiteral", function() { + var code = 'x = { "foo.##bar##.baz": "value" };'; + var ast = astFromString( code, "script" ); + + // Find the ObjectExpression + var objExpr = ast.body[ 1 ].right; + expect( objExpr.type ).toBe( "ObjectExpression" ); + + // Get the first property's key + var key = objExpr.properties[ 1 ].key; + + // Interpolated strings should be TemplateLiteral (like JS template literals) + // with quasis (string parts) and expressions (interpolated values) + expect( key.type ).toBe( "TemplateLiteral", "Interpolated struct key should be TemplateLiteral, not BinaryExpression" ); + + // Should have quasis and expressions arrays + expect( key ).toHaveKey( "quasis" ); + expect( key ).toHaveKey( "expressions" ); + + // quasis should contain the string parts: "foo.", ".baz" + expect( arrayLen( key.quasis ) ).toBe( 2 ); + expect( key.quasis[ 1 ].value ).toBe( "foo." ); + expect( key.quasis[ 2 ].value ).toBe( ".baz" ); + + // expressions should contain the interpolated identifier: bar + expect( arrayLen( key.expressions ) ).toBe( 1 ); + expect( key.expressions[ 1 ].type ).toBe( "Identifier" ); + expect( key.expressions[ 1 ].name ).toBe( "BAR" ); + }); + + it( "should allow round-trip of struct with interpolated keys", function() { + var code = 'x = { "prefix.##name##.suffix": "value" };'; + var ast = astFromString( code, "script" ); + + // The key should be representable in a way that can round-trip + var key = ast.body[ 1 ].right.properties[ 1 ].key; + + // Should be TemplateLiteral, not BinaryExpression + expect( key.type ).toBe( "TemplateLiteral", "Key should be TemplateLiteral for round-trip support" ); + expect( key.type ).notToBe( "BinaryExpression", "Key should not be broken into concatenation parts" ); + }); + + it( "should handle simple interpolation with no prefix/suffix", function() { + var code = 'x = { "##name##": "value" };'; + var ast = astFromString( code, "script" ); + + var key = ast.body[ 1 ].right.properties[ 1 ].key; + + // A simple "#name#" with no string parts becomes just the expression + // This is expected - it's effectively just the variable + // The key point is it's NOT a BinaryExpression CONCAT + expect( key.type ).notToBe( "BinaryExpression", "Should not be broken into concatenation parts" ); + // It should be an Identifier (the interpolated variable) + expect( key.type ).toBe( "Identifier" ); + expect( key.name ).toBe( "NAME" ); + }); + + }); + + } + +} From 37fae18fe37cedfdd192b8fbc2b443f006afcc6d Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Fri, 19 Dec 2025 03:00:15 +0100 Subject: [PATCH 35/71] LDEV-6018 AST unwrap CastExpression wrapper from concat operands --- test/functions/astFromString.cfc | 16 ++++++------- test/tickets/LDEV6018.cfc | 39 ++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 8 deletions(-) create mode 100644 test/tickets/LDEV6018.cfc diff --git a/test/functions/astFromString.cfc b/test/functions/astFromString.cfc index 9f01e2c7869..8e4bde869e3 100644 --- a/test/functions/astFromString.cfc +++ b/test/functions/astFromString.cfc @@ -21,49 +21,49 @@ component extends = "org.lucee.cfml.test.LuceeTestCase" { it( title = 'echo literal string', body = function( currentSpec ) { var result = astFromString("Susi"); assertEquals( - '{"start":{"line":1,"column":0,"offset":0},"end":{"line":1,"column":4,"offset":4},"type":"Program","body":[{"start":{"line":1,"column":0,"offset":0},"end":{"line":1,"column":4,"offset":4},"type":"ExpressionStatement","expression":{"start":{"line":1,"column":0,"offset":0},"end":{"line":1,"column":4,"offset":4},"type":"StringLiteral","value":"Susi","raw":"\"Susi\""}}]}', + '{"start":{"line":1,"column":0,"offset":0},"end":{"line":1,"column":4,"offset":4},"type":"Program","body":[{"start":{"line":1,"column":0,"offset":0},"end":{"line":1,"column":4,"offset":4},"type":"ExpressionStatement","expression":{"start":{"line":1,"column":0,"offset":0},"end":{"line":1,"column":4,"offset":4},"type":"StringLiteral","value":"Susi","raw":"\"Susi\""}}],"sourceType":"tag","dotNotationUpperCase":true,"handleUnquotedAttrValueAsString":true}', serializeJSON(var:result,compact:true) ); }); it( title = 'test loop tag', body = function( currentSpec ) { var result = astFromString(''); assertEquals( - '{"start":{"line":1,"column":0,"offset":0},"end":{"line":1,"column":44,"offset":44},"type":"Program","body":[{"start":{"line":1,"column":0,"offset":0},"end":{"line":1,"column":44,"offset":44},"type":"CFMLTag","isBuiltIn":true,"name":"loop","nameSpace":"cf","nameSpaceSeparator":"","fullname":"cfloop","attributes":[{"name":"from","type":"Attribute","value":{"start":{"line":1,"column":13,"offset":13},"end":{"line":1,"column":16,"offset":16},"type":"NumberLiteral","raw":"1","value":1}},{"name":"to","type":"Attribute","value":{"start":{"line":1,"column":20,"offset":20},"end":{"line":1,"column":24,"offset":24},"type":"NumberLiteral","raw":"10","value":10}},{"name":"index","type":"Attribute","value":{"start":{"line":1,"column":31,"offset":31},"end":{"line":1,"column":34,"offset":34},"type":"StringLiteral","value":"i","raw":"\"i\""}}],"body":{"type":"BlockStatement","body":[]}}]}', + '{"start":{"line":1,"column":0,"offset":0},"end":{"line":1,"column":44,"offset":44},"type":"Program","body":[{"start":{"line":1,"column":0,"offset":0},"end":{"line":1,"column":44,"offset":44},"type":"CFMLTag","isBuiltIn":true,"name":"loop","nameSpace":"cf","nameSpaceSeparator":"","fullname":"cfloop","attributes":[{"name":"from","type":"Attribute","value":{"start":{"line":1,"column":13,"offset":13},"end":{"line":1,"column":16,"offset":16},"type":"NumberLiteral","raw":"1","value":1}},{"name":"to","type":"Attribute","value":{"start":{"line":1,"column":20,"offset":20},"end":{"line":1,"column":24,"offset":24},"type":"NumberLiteral","raw":"10","value":10}},{"name":"index","type":"Attribute","value":{"start":{"line":1,"column":31,"offset":31},"end":{"line":1,"column":34,"offset":34},"type":"StringLiteral","value":"i","raw":"\"i\""}}],"body":{"type":"BlockStatement","body":[]}}],"sourceType":"tag","dotNotationUpperCase":true,"handleUnquotedAttrValueAsString":true}', serializeJSON(var:result,compact:true) ); }); it( title = 'test variable assignment with single data member', body = function( currentSpec ) { var result = astFromString('a.b.c=d;'); assertEquals( - '{"start":{"line":1,"column":0,"offset":0},"end":{"line":1,"column":29,"offset":29},"type":"Program","body":[{"start":{"line":1,"column":0,"offset":0},"end":{"line":1,"column":29,"offset":29},"type":"CFMLTag","isBuiltIn":true,"name":"script","nameSpace":"cf","nameSpaceSeparator":"","fullname":"cfscript","attributes":[],"body":{"type":"BlockStatement","body":[{"start":{"line":1,"column":10,"offset":10},"end":{"line":1,"column":17,"offset":17},"type":"AssignmentExpression","operator":"ASSIGN","left":{"start":{"line":1,"column":10,"offset":10},"end":{"line":1,"column":11,"offset":11},"type":"MemberExpression","computed":false,"object":{"type":"MemberExpression","computed":false,"object":{"type":"Identifier","name":"A"},"property":{"type":"Identifier","name":"B"}},"property":{"type":"Identifier","name":"C"}},"right":{"start":{"line":1,"column":16,"offset":16},"end":{"line":1,"column":17,"offset":17},"type":"Identifier","name":"D"}}]}}]}', + '{"start":{"line":1,"column":0,"offset":0},"end":{"line":1,"column":29,"offset":29},"type":"Program","body":[{"start":{"line":1,"column":0,"offset":0},"end":{"line":1,"column":29,"offset":29},"type":"CFMLTag","isBuiltIn":true,"name":"script","nameSpace":"cf","nameSpaceSeparator":"","fullname":"cfscript","attributes":[],"body":{"type":"BlockStatement","body":[{"start":{"line":1,"column":10,"offset":10},"end":{"line":1,"column":17,"offset":17},"type":"AssignmentExpression","operator":"ASSIGN","left":{"start":{"line":1,"column":10,"offset":10},"end":{"line":1,"column":11,"offset":11},"type":"MemberExpression","computed":false,"object":{"type":"MemberExpression","computed":false,"object":{"type":"Identifier","name":"A"},"property":{"type":"Identifier","name":"B"}},"property":{"type":"Identifier","name":"C"}},"right":{"start":{"line":1,"column":16,"offset":16},"end":{"line":1,"column":17,"offset":17},"type":"Identifier","name":"D"}}]}}],"sourceType":"tag","dotNotationUpperCase":true,"handleUnquotedAttrValueAsString":true}', serializeJSON(var:result,compact:true) ); }); it( title = 'test variable assignment with 2 data member', body = function( currentSpec ) { var result = astFromString('a.b.c=d.e;'); assertEquals( - '{"start":{"line":1,"column":0,"offset":0},"end":{"line":1,"column":31,"offset":31},"type":"Program","body":[{"start":{"line":1,"column":0,"offset":0},"end":{"line":1,"column":31,"offset":31},"type":"CFMLTag","isBuiltIn":true,"name":"script","nameSpace":"cf","nameSpaceSeparator":"","fullname":"cfscript","attributes":[],"body":{"type":"BlockStatement","body":[{"start":{"line":1,"column":10,"offset":10},"end":{"line":1,"column":19,"offset":19},"type":"AssignmentExpression","operator":"ASSIGN","left":{"start":{"line":1,"column":10,"offset":10},"end":{"line":1,"column":11,"offset":11},"type":"MemberExpression","computed":false,"object":{"type":"MemberExpression","computed":false,"object":{"type":"Identifier","name":"A"},"property":{"type":"Identifier","name":"B"}},"property":{"type":"Identifier","name":"C"}},"right":{"start":{"line":1,"column":16,"offset":16},"end":{"line":1,"column":17,"offset":17},"type":"MemberExpression","computed":false,"object":{"type":"Identifier","name":"D"},"property":{"type":"Identifier","name":"E"}}}]}}]}', + '{"start":{"line":1,"column":0,"offset":0},"end":{"line":1,"column":31,"offset":31},"type":"Program","body":[{"start":{"line":1,"column":0,"offset":0},"end":{"line":1,"column":31,"offset":31},"type":"CFMLTag","isBuiltIn":true,"name":"script","nameSpace":"cf","nameSpaceSeparator":"","fullname":"cfscript","attributes":[],"body":{"type":"BlockStatement","body":[{"start":{"line":1,"column":10,"offset":10},"end":{"line":1,"column":19,"offset":19},"type":"AssignmentExpression","operator":"ASSIGN","left":{"start":{"line":1,"column":10,"offset":10},"end":{"line":1,"column":11,"offset":11},"type":"MemberExpression","computed":false,"object":{"type":"MemberExpression","computed":false,"object":{"type":"Identifier","name":"A"},"property":{"type":"Identifier","name":"B"}},"property":{"type":"Identifier","name":"C"}},"right":{"start":{"line":1,"column":16,"offset":16},"end":{"line":1,"column":17,"offset":17},"type":"MemberExpression","computed":false,"object":{"type":"Identifier","name":"D"},"property":{"type":"Identifier","name":"E"}}}]}}],"sourceType":"tag","dotNotationUpperCase":true,"handleUnquotedAttrValueAsString":true}', serializeJSON(var:result,compact:true) ); }); it( title = 'test variable assignment with function call', body = function( currentSpec ) { var result = astFromString('a.b.c=d(1,true,"");'); assertEquals( - '{"start":{"line":1,"column":0,"offset":0},"end":{"line":1,"column":40,"offset":40},"type":"Program","body":[{"start":{"line":1,"column":0,"offset":0},"end":{"line":1,"column":40,"offset":40},"type":"CFMLTag","isBuiltIn":true,"name":"script","nameSpace":"cf","nameSpaceSeparator":"","fullname":"cfscript","attributes":[],"body":{"type":"BlockStatement","body":[{"start":{"line":1,"column":10,"offset":10},"end":{"line":1,"column":28,"offset":28},"type":"AssignmentExpression","operator":"ASSIGN","left":{"start":{"line":1,"column":10,"offset":10},"end":{"line":1,"column":11,"offset":11},"type":"MemberExpression","computed":false,"object":{"type":"MemberExpression","computed":false,"object":{"type":"Identifier","name":"A"},"property":{"type":"Identifier","name":"B"}},"property":{"type":"Identifier","name":"C"}},"right":{"start":{"line":1,"column":16,"offset":16},"end":{"line":1,"column":28,"offset":28},"type":"CallExpression","callee":{"type":"Identifier","name":"D"},"arguments":[{"start":{"line":1,"column":18,"offset":18},"end":{"line":1,"column":19,"offset":19},"type":"NumberLiteral","raw":"1","value":1},{"start":{"line":1,"column":20,"offset":20},"end":{"line":1,"column":24,"offset":24},"type":"BooleanLiteral","value":true},{"start":{"line":1,"column":25,"offset":25},"end":{"line":1,"column":27,"offset":27},"type":"StringLiteral","value":"","raw":"\"\""}]}}]}}]}', + '{"start":{"line":1,"column":0,"offset":0},"end":{"line":1,"column":40,"offset":40},"type":"Program","body":[{"start":{"line":1,"column":0,"offset":0},"end":{"line":1,"column":40,"offset":40},"type":"CFMLTag","isBuiltIn":true,"name":"script","nameSpace":"cf","nameSpaceSeparator":"","fullname":"cfscript","attributes":[],"body":{"type":"BlockStatement","body":[{"start":{"line":1,"column":10,"offset":10},"end":{"line":1,"column":28,"offset":28},"type":"AssignmentExpression","operator":"ASSIGN","left":{"start":{"line":1,"column":10,"offset":10},"end":{"line":1,"column":11,"offset":11},"type":"MemberExpression","computed":false,"object":{"type":"MemberExpression","computed":false,"object":{"type":"Identifier","name":"A"},"property":{"type":"Identifier","name":"B"}},"property":{"type":"Identifier","name":"C"}},"right":{"start":{"line":1,"column":16,"offset":16},"end":{"line":1,"column":28,"offset":28},"type":"CallExpression","callee":{"type":"Identifier","name":"D"},"arguments":[{"start":{"line":1,"column":18,"offset":18},"end":{"line":1,"column":19,"offset":19},"type":"NumberLiteral","raw":"1","value":1},{"start":{"line":1,"column":20,"offset":20},"end":{"line":1,"column":24,"offset":24},"type":"BooleanLiteral","value":true},{"start":{"line":1,"column":25,"offset":25},"end":{"line":1,"column":27,"offset":27},"type":"StringLiteral","value":"","raw":"\"\""}]}}]}}],"sourceType":"tag","dotNotationUpperCase":true,"handleUnquotedAttrValueAsString":true}', serializeJSON(var:result,compact:true) ); }); it( title = 'test variable assignment with scopes', body = function( currentSpec ) { var result = astFromString('variables.a=url.a;'); assertEquals( - '{"start":{"line":1,"column":0,"offset":0},"end":{"line":1,"column":39,"offset":39},"type":"Program","body":[{"start":{"line":1,"column":0,"offset":0},"end":{"line":1,"column":39,"offset":39},"type":"CFMLTag","isBuiltIn":true,"name":"script","nameSpace":"cf","nameSpaceSeparator":"","fullname":"cfscript","attributes":[],"body":{"type":"BlockStatement","body":[{"start":{"line":1,"column":10,"offset":10},"end":{"line":1,"column":27,"offset":27},"type":"AssignmentExpression","operator":"ASSIGN","left":{"start":{"line":1,"column":10,"offset":10},"end":{"line":1,"column":19,"offset":19},"type":"MemberExpression","computed":false,"object":{"type":"Identifier","name":"VARIABLES"},"property":{"type":"Identifier","name":"A"}},"right":{"start":{"line":1,"column":22,"offset":22},"end":{"line":1,"column":25,"offset":25},"type":"MemberExpression","computed":false,"object":{"type":"Identifier","name":"URL"},"property":{"type":"Identifier","name":"A"}}}]}}]}', + '{"start":{"line":1,"column":0,"offset":0},"end":{"line":1,"column":39,"offset":39},"type":"Program","body":[{"start":{"line":1,"column":0,"offset":0},"end":{"line":1,"column":39,"offset":39},"type":"CFMLTag","isBuiltIn":true,"name":"script","nameSpace":"cf","nameSpaceSeparator":"","fullname":"cfscript","attributes":[],"body":{"type":"BlockStatement","body":[{"start":{"line":1,"column":10,"offset":10},"end":{"line":1,"column":27,"offset":27},"type":"AssignmentExpression","operator":"ASSIGN","left":{"start":{"line":1,"column":10,"offset":10},"end":{"line":1,"column":19,"offset":19},"type":"MemberExpression","computed":false,"object":{"type":"Identifier","name":"VARIABLES"},"property":{"type":"Identifier","name":"A"}},"right":{"start":{"line":1,"column":22,"offset":22},"end":{"line":1,"column":25,"offset":25},"type":"MemberExpression","computed":false,"object":{"type":"Identifier","name":"URL"},"property":{"type":"Identifier","name":"A"}}}]}}],"sourceType":"tag","dotNotationUpperCase":true,"handleUnquotedAttrValueAsString":true}', serializeJSON(var:result,compact:true) ); }); it( title = 'test positional arguments', body = function( currentSpec ) { var result = astFromString('whatever(1,true,"abc");'); assertEquals( - '{"start":{"line":1,"column":0,"offset":0},"end":{"line":1,"column":44,"offset":44},"type":"Program","body":[{"start":{"line":1,"column":0,"offset":0},"end":{"line":1,"column":44,"offset":44},"type":"CFMLTag","isBuiltIn":true,"name":"script","nameSpace":"cf","nameSpaceSeparator":"","fullname":"cfscript","attributes":[],"body":{"type":"BlockStatement","body":[{"start":{"line":1,"column":10,"offset":10},"end":{"line":1,"column":32,"offset":32},"type":"CallExpression","callee":{"type":"Identifier","name":"WHATEVER"},"arguments":[{"start":{"line":1,"column":19,"offset":19},"end":{"line":1,"column":20,"offset":20},"type":"NumberLiteral","raw":"1","value":1},{"start":{"line":1,"column":21,"offset":21},"end":{"line":1,"column":25,"offset":25},"type":"BooleanLiteral","value":true},{"start":{"line":1,"column":26,"offset":26},"end":{"line":1,"column":31,"offset":31},"type":"StringLiteral","value":"abc","raw":"\"abc\""}]}]}}]}', + '{"start":{"line":1,"column":0,"offset":0},"end":{"line":1,"column":44,"offset":44},"type":"Program","body":[{"start":{"line":1,"column":0,"offset":0},"end":{"line":1,"column":44,"offset":44},"type":"CFMLTag","isBuiltIn":true,"name":"script","nameSpace":"cf","nameSpaceSeparator":"","fullname":"cfscript","attributes":[],"body":{"type":"BlockStatement","body":[{"start":{"line":1,"column":10,"offset":10},"end":{"line":1,"column":32,"offset":32},"type":"CallExpression","callee":{"type":"Identifier","name":"WHATEVER"},"arguments":[{"start":{"line":1,"column":19,"offset":19},"end":{"line":1,"column":20,"offset":20},"type":"NumberLiteral","raw":"1","value":1},{"start":{"line":1,"column":21,"offset":21},"end":{"line":1,"column":25,"offset":25},"type":"BooleanLiteral","value":true},{"start":{"line":1,"column":26,"offset":26},"end":{"line":1,"column":31,"offset":31},"type":"StringLiteral","value":"abc","raw":"\"abc\""}]}]}}],"sourceType":"tag","dotNotationUpperCase":true,"handleUnquotedAttrValueAsString":true}', serializeJSON(var:result,compact:true) ); }); @@ -71,7 +71,7 @@ component extends = "org.lucee.cfml.test.LuceeTestCase" { it( title = 'test named arguments', body = function( currentSpec ) { var result = astFromString('whatever(arg1=1, arg2=true, arg3="abc");'); assertEquals( - '{"start":{"line":1,"column":0,"offset":0},"end":{"line":1,"column":61,"offset":61},"type":"Program","body":[{"start":{"line":1,"column":0,"offset":0},"end":{"line":1,"column":61,"offset":61},"type":"CFMLTag","isBuiltIn":true,"name":"script","nameSpace":"cf","nameSpaceSeparator":"","fullname":"cfscript","attributes":[],"body":{"type":"BlockStatement","body":[{"start":{"line":1,"column":10,"offset":10},"end":{"line":1,"column":49,"offset":49},"type":"CallExpression","callee":{"type":"Identifier","name":"WHATEVER"},"arguments":[{"type":"NamedArgument","name":{"start":{"line":1,"column":19,"offset":19},"end":{"line":1,"column":23,"offset":23},"type":"Identifier","name":"ARG1"},"value":{"start":{"line":1,"column":24,"offset":24},"end":{"line":1,"column":25,"offset":25},"type":"NumberLiteral","raw":"1","value":1}},{"type":"NamedArgument","name":{"start":{"line":1,"column":27,"offset":27},"end":{"line":1,"column":31,"offset":31},"type":"Identifier","name":"ARG2"},"value":{"start":{"line":1,"column":32,"offset":32},"end":{"line":1,"column":36,"offset":36},"type":"BooleanLiteral","value":true}},{"type":"NamedArgument","name":{"start":{"line":1,"column":38,"offset":38},"end":{"line":1,"column":42,"offset":42},"type":"Identifier","name":"ARG3"},"value":{"start":{"line":1,"column":43,"offset":43},"end":{"line":1,"column":48,"offset":48},"type":"StringLiteral","value":"abc","raw":"\"abc\""}}]}]}}]}', + '{"start":{"line":1,"column":0,"offset":0},"end":{"line":1,"column":61,"offset":61},"type":"Program","body":[{"start":{"line":1,"column":0,"offset":0},"end":{"line":1,"column":61,"offset":61},"type":"CFMLTag","isBuiltIn":true,"name":"script","nameSpace":"cf","nameSpaceSeparator":"","fullname":"cfscript","attributes":[],"body":{"type":"BlockStatement","body":[{"start":{"line":1,"column":10,"offset":10},"end":{"line":1,"column":49,"offset":49},"type":"CallExpression","callee":{"type":"Identifier","name":"WHATEVER"},"arguments":[{"type":"NamedArgument","name":{"start":{"line":1,"column":19,"offset":19},"end":{"line":1,"column":23,"offset":23},"type":"Identifier","name":"ARG1"},"value":{"start":{"line":1,"column":24,"offset":24},"end":{"line":1,"column":25,"offset":25},"type":"NumberLiteral","raw":"1","value":1}},{"type":"NamedArgument","name":{"start":{"line":1,"column":27,"offset":27},"end":{"line":1,"column":31,"offset":31},"type":"Identifier","name":"ARG2"},"value":{"start":{"line":1,"column":32,"offset":32},"end":{"line":1,"column":36,"offset":36},"type":"BooleanLiteral","value":true}},{"type":"NamedArgument","name":{"start":{"line":1,"column":38,"offset":38},"end":{"line":1,"column":42,"offset":42},"type":"Identifier","name":"ARG3"},"value":{"start":{"line":1,"column":43,"offset":43},"end":{"line":1,"column":48,"offset":48},"type":"StringLiteral","value":"abc","raw":"\"abc\""}}]}]}}],"sourceType":"tag","dotNotationUpperCase":true,"handleUnquotedAttrValueAsString":true}', serializeJSON(var:result,compact:true) ); }); diff --git a/test/tickets/LDEV6018.cfc b/test/tickets/LDEV6018.cfc new file mode 100644 index 00000000000..e52bf0b6e6b --- /dev/null +++ b/test/tickets/LDEV6018.cfc @@ -0,0 +1,39 @@ +component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { + + function run( testResults, testBox ) { + + describe( "LDEV-6018: String concatenation should not wrap operands in CastExpression", function() { + + it( "should not add typeAnnotation CastExpression wrapper to concat operands", function() { + var code = 'x = foo & "bar";'; + var ast = astFromString( code, "script" ); + + // Find the BinaryExpression + var binExpr = ast.body[ 1 ].right; + expect( binExpr.type ).toBe( "BinaryExpression" ); + expect( binExpr.operator ).toBe( "CONCAT" ); + + // Bug: left operand is wrapped in CastExpression with typeAnnotation: "string" + // This is internal type coercion info that shouldn't be in the AST + expect( binExpr.left.type ).toBe( "Identifier", "Left operand should be Identifier, not CastExpression" ); + expect( binExpr.left.name ).toBe( "FOO" ); + }); + + it( "should preserve simple identifier in string concatenation", function() { + var code = 'result = path & "/file.txt";'; + var ast = astFromString( code, "script" ); + + var binExpr = ast.body[ 1 ].right; + expect( binExpr.type ).toBe( "BinaryExpression" ); + + // The left operand should be a simple Identifier + // Not wrapped in CastExpression with typeAnnotation + expect( binExpr.left ).notToHaveKey( "typeAnnotation", "Operand should not have typeAnnotation" ); + expect( binExpr.left.type ).toBe( "Identifier" ); + }); + + }); + + } + +} From bbaf0925ebbb2a6e12ec78d3f79d3bfd5b2660cb Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Fri, 19 Dec 2025 13:30:27 +0100 Subject: [PATCH 36/71] LDEV-6022 preserve string literal concatenation in AST --- .../transformer/bytecode/BytecodeFactory.java | 4 +- .../interpreter/InterpreterFactory.java | 3 +- test/tickets/LDEV6022.cfc | 52 +++++++++++++++++++ 3 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 test/tickets/LDEV6022.cfc diff --git a/core/src/main/java/lucee/transformer/bytecode/BytecodeFactory.java b/core/src/main/java/lucee/transformer/bytecode/BytecodeFactory.java index b3a47a8d99c..d09a59494b1 100644 --- a/core/src/main/java/lucee/transformer/bytecode/BytecodeFactory.java +++ b/core/src/main/java/lucee/transformer/bytecode/BytecodeFactory.java @@ -288,7 +288,9 @@ public Variable createVariable(int scope, Position start, Position end) { @Override public ExprString opString(Expression left, Expression right) { - return OpString.toExprString(left, right, true); + // Pass false to preserve string literal structure for AST output (LDEV-6022) + // Otherwise "" + expect( concat1.right.type ).toBe( "StringLiteral" ); + expect( concat1.right.value ).toBe( "ript>" ); + }); + + it( "preserves two-part string concatenation with cf tag pattern", function() { + var code = 'x = "";'; + var ast = astFromString( code, "script" ); + + var assign = ast.body[1]; + var concat = assign.right; + + expect( concat.type ).toBe( "BinaryExpression" ); + expect( concat.left.value ).toBe( "" ); + }); + + }); + } + +} From 8aa46c0760431115f44595c32479fbd58a27766c Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Fri, 19 Dec 2025 16:10:03 +0100 Subject: [PATCH 37/71] LDEV-6024 fix computed member expression in scope access AST --- .../bytecode/expression/var/VariableImpl.java | 39 ++++++++---- test/tickets/LDEV6024.cfc | 62 +++++++++++++++++++ 2 files changed, 89 insertions(+), 12 deletions(-) create mode 100644 test/tickets/LDEV6024.cfc diff --git a/core/src/main/java/lucee/transformer/bytecode/expression/var/VariableImpl.java b/core/src/main/java/lucee/transformer/bytecode/expression/var/VariableImpl.java index d0b2765febe..874a4842df6 100644 --- a/core/src/main/java/lucee/transformer/bytecode/expression/var/VariableImpl.java +++ b/core/src/main/java/lucee/transformer/bytecode/expression/var/VariableImpl.java @@ -1134,10 +1134,13 @@ private static void buildMemberExpressionIterative(Struct sct, List memb if (member instanceof FunctionMember) { FunctionMember fm = (FunctionMember) member; - String funcName = getName(fm).toString(); + Object funcNameObj = getName(fm); + // Check if this is a dynamic function name (computed property access) + boolean isComputedCall = funcNameObj instanceof Struct; + String funcName = isComputedCall ? null : funcNameObj.toString(); // LDEV-6011: Handle internal literal functions as proper AST node types - if (current == null && member instanceof BIF) { + if (current == null && member instanceof BIF && funcName != null) { if ("_literalStruct".equalsIgnoreCase(funcName) || "_literalOrderedStruct".equalsIgnoreCase(funcName)) { // Output as ObjectExpression newNode.setEL(KeyConstants._type, "ObjectExpression"); @@ -1226,23 +1229,35 @@ else if ("_createComponent".equalsIgnoreCase(funcName)) { // Set callee to current chain (or base identifier) if (current == null) { - // First element - base identifier - Struct callee = new StructImpl(Struct.TYPE_LINKED); - callee.setEL(KeyConstants._type, "Identifier"); - callee.setEL(KeyConstants._name, funcName); - newNode.setEL(KeyConstants._callee, callee); + // First element - base identifier or computed call + if (isComputedCall) { + // Computed call like variables[expr]() - callee is the computed member expression + newNode.setEL(KeyConstants._callee, funcNameObj); + } + else { + Struct callee = new StructImpl(Struct.TYPE_LINKED); + callee.setEL(KeyConstants._type, "Identifier"); + callee.setEL(KeyConstants._name, funcName); + newNode.setEL(KeyConstants._callee, callee); + } } else { // Method call on object - create MemberExpression for callee Struct callee = new StructImpl(Struct.TYPE_LINKED); callee.setEL(KeyConstants._type, "MemberExpression"); - callee.setEL(KeyConstants._computed, false); + callee.setEL(KeyConstants._computed, isComputedCall); callee.setEL(KeyConstants._object, current); - Struct property = new StructImpl(Struct.TYPE_LINKED); - property.setEL(KeyConstants._type, "Identifier"); - property.setEL(KeyConstants._name, funcName); - callee.setEL(KeyConstants._property, property); + if (isComputedCall) { + // Computed property access - use the dumped expression + callee.setEL(KeyConstants._property, funcNameObj); + } + else { + Struct property = new StructImpl(Struct.TYPE_LINKED); + property.setEL(KeyConstants._type, "Identifier"); + property.setEL(KeyConstants._name, funcName); + callee.setEL(KeyConstants._property, property); + } newNode.setEL(KeyConstants._callee, callee); } diff --git a/test/tickets/LDEV6024.cfc b/test/tickets/LDEV6024.cfc new file mode 100644 index 00000000000..f51ec785641 --- /dev/null +++ b/test/tickets/LDEV6024.cfc @@ -0,0 +1,62 @@ +component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { + + function run( testResults, testBox ) { + describe( "LDEV-6024: Computed member expression inside scope access", function() { + + it( "should preserve nested computed member expression in AST", function() { + var code = 'return variables[local.functionName[1]]();'; + var ast = astFromString( code, "script" ); + + // The return statement + var returnStmt = ast.body[1]; + expect( returnStmt.type ).toBe( "ReturnStatement" ); + + // The call expression: variables[...]()` + var callExpr = returnStmt.argument; + expect( callExpr.type ).toBe( "CallExpression" ); + + // The callee is a MemberExpression: variables[local.functionName[1]] + var callee = callExpr.callee; + expect( callee.type ).toBe( "MemberExpression" ); + expect( callee.computed ).toBe( true ); + + // Object should be VARIABLES identifier + expect( callee.object.type ).toBe( "Identifier" ); + expect( callee.object.name ).toBe( "VARIABLES" ); + + // Property is wrapped in CastExpression (cast to string for bracket access) + var prop = callee.property; + expect( prop.type ).toBe( "CastExpression" ); + expect( prop.typeAnnotation ).toBe( "string" ); + + // The argument inside CastExpression should be the MemberExpression + var innerExpr = prop.argument; + expect( innerExpr.type ).toBe( "MemberExpression" ); + expect( innerExpr.computed ).toBe( true ); + + // The inner expression is local.functionName[1] + expect( innerExpr.object.type ).toBe( "MemberExpression" ); + expect( innerExpr.object.property.name ).toBe( "FUNCTIONNAME" ); + }); + + it( "should handle simple computed scope access", function() { + var code = 'return variables[key];'; + var ast = astFromString( code, "script" ); + + var returnStmt = ast.body[1]; + var memberExpr = returnStmt.argument; + + expect( memberExpr.type ).toBe( "MemberExpression" ); + expect( memberExpr.computed ).toBe( true ); + expect( memberExpr.object.name ).toBe( "VARIABLES" ); + + // Property is wrapped in CastExpression (cast to string for bracket access) + expect( memberExpr.property.type ).toBe( "CastExpression" ); + expect( memberExpr.property.argument.type ).toBe( "Identifier" ); + expect( memberExpr.property.argument.name ).toBe( "KEY" ); + }); + + }); + } + +} From 0c9ca8ef921869b09b5736fdd12e4ddcd3484d95 Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Fri, 19 Dec 2025 17:40:42 +0100 Subject: [PATCH 38/71] LDEV-5989 AST parses interpolated rtexprvalue=false attributes as expressions --- .../transformer/cfml/evaluator/impl/Loop.java | 22 ++++++- .../transformer/cfml/tag/CFMLTransformer.java | 5 +- test/tickets/LDEV5989.cfc | 61 ++++++++++--------- test/tickets/LDEV5989/expressionAttr.cfm | 1 + 4 files changed, 58 insertions(+), 31 deletions(-) create mode 100644 test/tickets/LDEV5989/expressionAttr.cfm diff --git a/core/src/main/java/lucee/transformer/cfml/evaluator/impl/Loop.java b/core/src/main/java/lucee/transformer/cfml/evaluator/impl/Loop.java index 2ef37be6feb..1f768e55b6c 100755 --- a/core/src/main/java/lucee/transformer/cfml/evaluator/impl/Loop.java +++ b/core/src/main/java/lucee/transformer/cfml/evaluator/impl/Loop.java @@ -182,9 +182,29 @@ public void evaluate(Tag tag, TagLibTag tagLibTag, FunctionLib flibs) throws Eva throw new EvaluatorException("Wrong Context, Invalid combination of Attributes"); } + // Get the condition attribute value + Attribute condAttr = tag.getAttribute("condition"); + Expression condValue = condAttr.getValue(); + + // If the condition is already a non-literal expression (e.g., parsed in AST mode), + // use it directly instead of trying to re-parse from a string literal + if (!(condValue instanceof LitString)) { + // Already an expression - just ensure it's cast to boolean + try { + PageImpl page = (PageImpl) ASMUtil.getAncestorPage(null, tag); + tag.addAttribute(new Attribute(false, "condition", page.getFactory().toExprBoolean(condValue), "boolean")); + } + catch (Exception e) { + throw new EvaluatorException(e.getMessage()); + } + loop.setType(TagLoop.TYPE_CONDITION); + return; + } + + // Original behavior: parse the string literal as an expression TagLib tagLib = tagLibTag.getTagLib(); ExprTransformer transformer; - String text = ASMUtil.getAttributeString(tag, "condition"); + String text = ((LitString) condValue).getString(); try { transformer = tagLib.getExprTransfomer(); diff --git a/core/src/main/java/lucee/transformer/cfml/tag/CFMLTransformer.java b/core/src/main/java/lucee/transformer/cfml/tag/CFMLTransformer.java index 03eb6b9a3cd..be8fed2d07e 100755 --- a/core/src/main/java/lucee/transformer/cfml/tag/CFMLTransformer.java +++ b/core/src/main/java/lucee/transformer/cfml/tag/CFMLTransformer.java @@ -1275,7 +1275,10 @@ public static Expression attributeValue(Data data, TagLibTag tag, String type, b Expression expr; try { ExprTransformer transfomer = null; - if (parseExpression) { + // In AST mode, always use full expression parsing so interpolated expressions like + // cfloop condition="#expr#" show the parsed expression structure, not a StringLiteral. + // The rtexprvalue=false flag is only relevant for bytecode generation, not AST output. + if (parseExpression || data.ast) { transfomer = tag.getTagLib().getExprTransfomer(); } else { diff --git a/test/tickets/LDEV5989.cfc b/test/tickets/LDEV5989.cfc index cc8559e1fff..5d2a7a02424 100644 --- a/test/tickets/LDEV5989.cfc +++ b/test/tickets/LDEV5989.cfc @@ -4,9 +4,9 @@ component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { function run( testResults, testBox ) { - describe( "LDEV-5989: Interpolated attribute values have escaped hashes in raw field", function() { + describe( "LDEV-5989: Interpolated attribute values should be parsed as expressions", function() { - it( "raw should contain original single hashes for interpolated attributes", function() { + it( "interpolated condition attribute should be parsed as expression not StringLiteral", function() { var code = fileRead( variables.testDir & "interpolatedAttr.cfm" ); var ast = astFromString( code ); @@ -14,40 +14,43 @@ component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { var attr = findAttribute( ast, "condition" ); expect( attr ).notToBeNull( "condition attribute should be present in AST" ); - // The raw field should contain single hashes like the original source - // NOT escaped hashes like "##it.hasNext()##" - var rawValue = attr.value.raw; + // The value should be parsed as an expression (CastExpression or CallExpression) + // NOT as a StringLiteral with hashes in the value + expect( attr.value.type ).notToBe( "StringLiteral", + "Interpolated attribute should be parsed as expression, not StringLiteral. Got type: " & attr.value.type ); + }); - // Check for doubled hashes (##) in raw - use chr(35) to avoid CFML escaping issues - // If raw has ##, find() will locate chr(35)&chr(35) sequence - var doubleHash = chr( 35 ) & chr( 35 ); - var hasDoubleHash = find( doubleHash, rawValue ) > 0; + it( "condition with call expression should parse the function call", function() { + var code = fileRead( variables.testDir & "expressionAttr.cfm" ); + var ast = astFromString( code ); - // If hasDoubleHash is true, raw contains ## which is the bug - expect( hasDoubleHash ).toBeFalse( "Raw should contain single hashes, not escaped ## - got: " & rawValue ); - }); + var attr = findAttribute( ast, "condition" ); + expect( attr ).notToBeNull( "condition attribute should be present" ); - it( "round-trip should preserve interpolated attribute values", function() { - // Parse file with interpolated attribute - var code1 = fileRead( variables.testDir & "roundtrip1.cfm" ); - var ast1 = astFromString( code1 ); + // Value should be a CastExpression (toBoolean) wrapping a CallExpression + // or directly a CallExpression depending on implementation + var valueType = attr.value.type; + expect( valueType == "CallExpression" || valueType == "CastExpression" ).toBeTrue( + "Expected CallExpression or CastExpression, got: " & valueType ); + }); - var attr1 = findAttribute( ast1, "condition" ); - expect( attr1 ).notToBeNull( "First parse should find condition attribute" ); - var raw1 = attr1.value.raw; + it( "condition expression should have correct call structure", function() { + // Parse cfloop with condition containing interpolated call expression + var code = fileRead( variables.testDir & "interpolatedAttr.cfm" ); + var ast = astFromString( code ); - // Build round-trip file using template - var template = fileRead( variables.testDir & "loopTemplate.txt" ); - var code2 = replace( template, "%%CONDITION%%", raw1 ); - fileWrite( variables.testDir & "roundtrip2.cfm", code2 ); + var attr = findAttribute( ast, "condition" ); + expect( attr ).notToBeNull( "condition attribute should be present" ); - // Parse the round-trip file - var ast2 = astFromString( code2 ); - var attr2 = findAttribute( ast2, "condition" ); - expect( attr2 ).notToBeNull( "Second parse should find condition attribute" ); + // Navigate to the actual call expression (may be wrapped in CastExpression) + var expr = attr.value; + if ( expr.type == "CastExpression" ) { + expr = expr.argument; + } - // Raw should be stable - not doubling hashes each round - expect( attr2.value.raw ).toBe( raw1, "Raw should be stable after round-trip, not doubling hashes" ); + // Should be a CallExpression for it.hasNext() + expect( expr.type ).toBe( "CallExpression", "Should be a CallExpression" ); + expect( expr.callee.type ).toBe( "MemberExpression", "Callee should be MemberExpression" ); }); }); diff --git a/test/tickets/LDEV5989/expressionAttr.cfm b/test/tickets/LDEV5989/expressionAttr.cfm new file mode 100644 index 00000000000..39e5de31a2e --- /dev/null +++ b/test/tickets/LDEV5989/expressionAttr.cfm @@ -0,0 +1 @@ + From 35bc733d0d35642c5afd7d665f5e1090abd117f6 Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Fri, 19 Dec 2025 18:11:59 +0100 Subject: [PATCH 39/71] LDEV-6027 preserve property attribute declaration order in AST --- .../script/AbstrCFMLScriptTransformer.java | 3 +- test/tickets/LDEV6027.cfc | 57 +++++++++++++++++++ test/tickets/LDEV6027/PropertyOrder.cfc | 4 ++ 3 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 test/tickets/LDEV6027.cfc create mode 100644 test/tickets/LDEV6027/PropertyOrder.cfc diff --git a/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java b/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java index 5262706a777..db50432d0b6 100755 --- a/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java +++ b/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java @@ -1741,7 +1741,8 @@ private final Tag _propertyStatement(Data data, Body parent) throws TemplateExce Attribute attr; // first fill all regular attribute -> name="value" - for (int i = attrs.length - 1; i >= 0; i--) { + // LDEV-6027: iterate forward to preserve declaration order in AST + for (int i = 0; i < attrs.length; i++) { attr = attrs[i]; if (!isNull(attr.getValue())) { if (attr.getName().equalsIgnoreCase("name")) { diff --git a/test/tickets/LDEV6027.cfc b/test/tickets/LDEV6027.cfc new file mode 100644 index 00000000000..1e6327f9209 --- /dev/null +++ b/test/tickets/LDEV6027.cfc @@ -0,0 +1,57 @@ +component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { + + variables.testDir = getDirectoryFromPath( getCurrentTemplatePath() ) & "LDEV6027/"; + + function run( testResults, testBox ) { + + describe( "LDEV-6027: Property attribute order should be preserved in AST", function() { + + it( "property attributes should maintain declaration order", function() { + var ast = astFromPath( variables.testDir & "PropertyOrder.cfc" ); + + // Find first property node + var prop = findFirstProperty( ast ); + expect( prop ).notToBeNull( "Should find a property in the AST" ); + + // Get attribute names in order + var attrNames = []; + for ( var attr in ( prop.attributes ?: [] ) ) { + arrayAppend( attrNames, attr.name ); + } + + // Should be: name, type, default (declaration order) + // Bug: Currently returns alphabetical order + expect( attrNames[ 1 ] ).toBe( "name", "First attribute should be 'name', got: " & attrNames.toList() ); + expect( attrNames[ 2 ] ).toBe( "type", "Second attribute should be 'type', got: " & attrNames.toList() ); + expect( attrNames[ 3 ] ).toBe( "default", "Third attribute should be 'default', got: " & attrNames.toList() ); + }); + + }); + + } + + /** + * Recursively find the first property CFMLTag node + */ + private function findFirstProperty( required struct node ) { + if ( ( node.type ?: "" ) == "CFMLTag" && ( node.name ?: "" ) == "property" ) { + return node; + } + for ( var key in node ) { + var val = node[ key ]; + if ( isStruct( val ) ) { + var result = findFirstProperty( val ); + if ( !isNull( result ) ) return result; + } else if ( isArray( val ) ) { + for ( var item in val ) { + if ( isStruct( item ) ) { + var result = findFirstProperty( item ); + if ( !isNull( result ) ) return result; + } + } + } + } + return; + } + +} diff --git a/test/tickets/LDEV6027/PropertyOrder.cfc b/test/tickets/LDEV6027/PropertyOrder.cfc new file mode 100644 index 00000000000..ccd56db32c1 --- /dev/null +++ b/test/tickets/LDEV6027/PropertyOrder.cfc @@ -0,0 +1,4 @@ +component { + property name="id" type="numeric" default="0"; + property name="title" type="string" default=""; +} From bee9f12d239f04f0994a1ca729c9ff6df4eefd09 Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Fri, 19 Dec 2025 18:50:01 +0100 Subject: [PATCH 40/71] LDEV-6015 add quoteChar to TemplateLiteral AST nodes --- .../lucee/transformer/bytecode/op/OpString.java | 14 ++++++++++++++ .../cfml/expression/AbstrCFMLExprTransformer.java | 4 ++++ test/tickets/LDEV6015.cfc | 11 +++++++++++ test/tickets/LDEV6015/templateLiteralSingle.cfc | 3 +++ 4 files changed, 32 insertions(+) create mode 100644 test/tickets/LDEV6015/templateLiteralSingle.cfc diff --git a/core/src/main/java/lucee/transformer/bytecode/op/OpString.java b/core/src/main/java/lucee/transformer/bytecode/op/OpString.java index 6edcdc74b1f..4e53949f935 100755 --- a/core/src/main/java/lucee/transformer/bytecode/op/OpString.java +++ b/core/src/main/java/lucee/transformer/bytecode/op/OpString.java @@ -40,6 +40,7 @@ public final class OpString extends ExpressionBase implements ExprString { private ExprString right; private ExprString left; private boolean fromInterpolation = false; + private char quoteChar; // Original quote character (' or ") for TemplateLiteral AST dump // String concat (String) private final static Method METHOD_CONCAT = new Method("concat", Types.STRING, new Type[] { Types.STRING }); @@ -59,6 +60,14 @@ public boolean isFromInterpolation() { return fromInterpolation; } + public void setQuoteChar(char quoteChar) { + this.quoteChar = quoteChar; + } + + public char getQuoteChar() { + return quoteChar; + } + public static ExprString toExprString(Expression left, Expression right, boolean concatStatic) { if (concatStatic && left instanceof Literal && right instanceof Literal) { String l = ((Literal) left).getString(); @@ -109,6 +118,11 @@ public void dump(Struct sct) { sct.setEL(KeyConstants._type, "TemplateLiteral"); + // Include quoteChar if set (for round-trip fidelity) + if (quoteChar != 0) { + sct.setEL("quoteChar", String.valueOf(quoteChar)); + } + // Add quasis array Array quasisArr = new ArrayImpl(); for (Expression q : quasis) { diff --git a/core/src/main/java/lucee/transformer/cfml/expression/AbstrCFMLExprTransformer.java b/core/src/main/java/lucee/transformer/cfml/expression/AbstrCFMLExprTransformer.java index e9aa46a5618..5d0b9515538 100755 --- a/core/src/main/java/lucee/transformer/cfml/expression/AbstrCFMLExprTransformer.java +++ b/core/src/main/java/lucee/transformer/cfml/expression/AbstrCFMLExprTransformer.java @@ -51,6 +51,7 @@ import lucee.transformer.bytecode.literal.LitStringImpl; import lucee.transformer.bytecode.literal.Null; import lucee.transformer.bytecode.literal.NullConstant; +import lucee.transformer.bytecode.op.OpString; import lucee.transformer.bytecode.op.OpVariable; import lucee.transformer.bytecode.statement.tag.TagComponent; import lucee.transformer.bytecode.statement.udf.Function; @@ -1163,6 +1164,9 @@ else if (str.length() != 0) { if (expr instanceof LitStringImpl) { ((LitStringImpl) expr).setQuoteChar(quoter); } + else if (expr instanceof OpString) { + ((OpString) expr).setQuoteChar(quoter); + } return expr; diff --git a/test/tickets/LDEV6015.cfc b/test/tickets/LDEV6015.cfc index eadc636a2d0..0712fd568bc 100644 --- a/test/tickets/LDEV6015.cfc +++ b/test/tickets/LDEV6015.cfc @@ -59,6 +59,17 @@ component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { expect( strLiteral.quoteChar ).toBe( "'" ); }); + it( "should preserve single quote in TemplateLiteral (interpolated string)", function() { + var ast = astFromPath( variables.testDir & "templateLiteralSingle.cfc" ); + var comp = ast.body[1]; + var assignment = comp.body.body[1]; + var templateLiteral = assignment.right; + + expect( templateLiteral.type ).toBe( "TemplateLiteral" ); + expect( templateLiteral ).toHaveKey( "quoteChar", "TemplateLiteral should have quoteChar field like StringLiteral" ); + expect( templateLiteral.quoteChar ).toBe( "'", "Should preserve single quote for interpolated string" ); + }); + }); } diff --git a/test/tickets/LDEV6015/templateLiteralSingle.cfc b/test/tickets/LDEV6015/templateLiteralSingle.cfc new file mode 100644 index 00000000000..e71a349149f --- /dev/null +++ b/test/tickets/LDEV6015/templateLiteralSingle.cfc @@ -0,0 +1,3 @@ +component { + x = 'test="#value#"'; +} From bbaa2f52b755c17071532cd990ad765bce004108 Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Fri, 19 Dec 2025 19:24:44 +0100 Subject: [PATCH 41/71] LDEV-6015 update astFromString tests for quoteChar field --- test/functions/astFromString.cfc | 80 +++++++++++++++++++++++++------- 1 file changed, 64 insertions(+), 16 deletions(-) diff --git a/test/functions/astFromString.cfc b/test/functions/astFromString.cfc index 8e4bde869e3..b62e03b6b9f 100644 --- a/test/functions/astFromString.cfc +++ b/test/functions/astFromString.cfc @@ -27,10 +27,8 @@ component extends = "org.lucee.cfml.test.LuceeTestCase" { }); it( title = 'test loop tag', body = function( currentSpec ) { var result = astFromString(''); - assertEquals( - '{"start":{"line":1,"column":0,"offset":0},"end":{"line":1,"column":44,"offset":44},"type":"Program","body":[{"start":{"line":1,"column":0,"offset":0},"end":{"line":1,"column":44,"offset":44},"type":"CFMLTag","isBuiltIn":true,"name":"loop","nameSpace":"cf","nameSpaceSeparator":"","fullname":"cfloop","attributes":[{"name":"from","type":"Attribute","value":{"start":{"line":1,"column":13,"offset":13},"end":{"line":1,"column":16,"offset":16},"type":"NumberLiteral","raw":"1","value":1}},{"name":"to","type":"Attribute","value":{"start":{"line":1,"column":20,"offset":20},"end":{"line":1,"column":24,"offset":24},"type":"NumberLiteral","raw":"10","value":10}},{"name":"index","type":"Attribute","value":{"start":{"line":1,"column":31,"offset":31},"end":{"line":1,"column":34,"offset":34},"type":"StringLiteral","value":"i","raw":"\"i\""}}],"body":{"type":"BlockStatement","body":[]}}],"sourceType":"tag","dotNotationUpperCase":true,"handleUnquotedAttrValueAsString":true}', - serializeJSON(var:result,compact:true) - ); + var expected = deserializeJSON( '{"start":{"line":1,"column":0,"offset":0},"end":{"line":1,"column":44,"offset":44},"type":"Program","body":[{"start":{"line":1,"column":0,"offset":0},"end":{"line":1,"column":44,"offset":44},"type":"CFMLTag","isBuiltIn":true,"name":"loop","nameSpace":"cf","nameSpaceSeparator":"","fullname":"cfloop","attributes":[{"name":"from","type":"Attribute","value":{"start":{"line":1,"column":13,"offset":13},"end":{"line":1,"column":16,"offset":16},"type":"NumberLiteral","raw":"1","value":1}},{"name":"to","type":"Attribute","value":{"start":{"line":1,"column":20,"offset":20},"end":{"line":1,"column":24,"offset":24},"type":"NumberLiteral","raw":"10","value":10}},{"name":"index","type":"Attribute","value":{"start":{"line":1,"column":31,"offset":31},"end":{"line":1,"column":34,"offset":34},"type":"StringLiteral","value":"i","raw":"\"i\"","quoteChar":"\""}}],"body":{"type":"BlockStatement","body":[]}}],"sourceType":"tag","dotNotationUpperCase":true,"handleUnquotedAttrValueAsString":true}' ); + assertASTMatches( expected, result ); }); it( title = 'test variable assignment with single data member', body = function( currentSpec ) { var result = astFromString('a.b.c=d;'); @@ -48,10 +46,8 @@ component extends = "org.lucee.cfml.test.LuceeTestCase" { }); it( title = 'test variable assignment with function call', body = function( currentSpec ) { var result = astFromString('a.b.c=d(1,true,"");'); - assertEquals( - '{"start":{"line":1,"column":0,"offset":0},"end":{"line":1,"column":40,"offset":40},"type":"Program","body":[{"start":{"line":1,"column":0,"offset":0},"end":{"line":1,"column":40,"offset":40},"type":"CFMLTag","isBuiltIn":true,"name":"script","nameSpace":"cf","nameSpaceSeparator":"","fullname":"cfscript","attributes":[],"body":{"type":"BlockStatement","body":[{"start":{"line":1,"column":10,"offset":10},"end":{"line":1,"column":28,"offset":28},"type":"AssignmentExpression","operator":"ASSIGN","left":{"start":{"line":1,"column":10,"offset":10},"end":{"line":1,"column":11,"offset":11},"type":"MemberExpression","computed":false,"object":{"type":"MemberExpression","computed":false,"object":{"type":"Identifier","name":"A"},"property":{"type":"Identifier","name":"B"}},"property":{"type":"Identifier","name":"C"}},"right":{"start":{"line":1,"column":16,"offset":16},"end":{"line":1,"column":28,"offset":28},"type":"CallExpression","callee":{"type":"Identifier","name":"D"},"arguments":[{"start":{"line":1,"column":18,"offset":18},"end":{"line":1,"column":19,"offset":19},"type":"NumberLiteral","raw":"1","value":1},{"start":{"line":1,"column":20,"offset":20},"end":{"line":1,"column":24,"offset":24},"type":"BooleanLiteral","value":true},{"start":{"line":1,"column":25,"offset":25},"end":{"line":1,"column":27,"offset":27},"type":"StringLiteral","value":"","raw":"\"\""}]}}]}}],"sourceType":"tag","dotNotationUpperCase":true,"handleUnquotedAttrValueAsString":true}', - serializeJSON(var:result,compact:true) - ); + var expected = deserializeJSON( '{"start":{"line":1,"column":0,"offset":0},"end":{"line":1,"column":40,"offset":40},"type":"Program","body":[{"start":{"line":1,"column":0,"offset":0},"end":{"line":1,"column":40,"offset":40},"type":"CFMLTag","isBuiltIn":true,"name":"script","nameSpace":"cf","nameSpaceSeparator":"","fullname":"cfscript","attributes":[],"body":{"type":"BlockStatement","body":[{"start":{"line":1,"column":10,"offset":10},"end":{"line":1,"column":28,"offset":28},"type":"AssignmentExpression","operator":"ASSIGN","left":{"start":{"line":1,"column":10,"offset":10},"end":{"line":1,"column":11,"offset":11},"type":"MemberExpression","computed":false,"object":{"type":"MemberExpression","computed":false,"object":{"type":"Identifier","name":"A"},"property":{"type":"Identifier","name":"B"}},"property":{"type":"Identifier","name":"C"}},"right":{"start":{"line":1,"column":16,"offset":16},"end":{"line":1,"column":28,"offset":28},"type":"CallExpression","callee":{"type":"Identifier","name":"D"},"arguments":[{"start":{"line":1,"column":18,"offset":18},"end":{"line":1,"column":19,"offset":19},"type":"NumberLiteral","raw":"1","value":1},{"start":{"line":1,"column":20,"offset":20},"end":{"line":1,"column":24,"offset":24},"type":"BooleanLiteral","value":true},{"start":{"line":1,"column":25,"offset":25},"end":{"line":1,"column":27,"offset":27},"type":"StringLiteral","value":"","raw":"\"\"","quoteChar":"\""}]}}]}}],"sourceType":"tag","dotNotationUpperCase":true,"handleUnquotedAttrValueAsString":true}' ); + assertASTMatches( expected, result ); }); it( title = 'test variable assignment with scopes', body = function( currentSpec ) { var result = astFromString('variables.a=url.a;'); @@ -62,18 +58,14 @@ component extends = "org.lucee.cfml.test.LuceeTestCase" { }); it( title = 'test positional arguments', body = function( currentSpec ) { var result = astFromString('whatever(1,true,"abc");'); - assertEquals( - '{"start":{"line":1,"column":0,"offset":0},"end":{"line":1,"column":44,"offset":44},"type":"Program","body":[{"start":{"line":1,"column":0,"offset":0},"end":{"line":1,"column":44,"offset":44},"type":"CFMLTag","isBuiltIn":true,"name":"script","nameSpace":"cf","nameSpaceSeparator":"","fullname":"cfscript","attributes":[],"body":{"type":"BlockStatement","body":[{"start":{"line":1,"column":10,"offset":10},"end":{"line":1,"column":32,"offset":32},"type":"CallExpression","callee":{"type":"Identifier","name":"WHATEVER"},"arguments":[{"start":{"line":1,"column":19,"offset":19},"end":{"line":1,"column":20,"offset":20},"type":"NumberLiteral","raw":"1","value":1},{"start":{"line":1,"column":21,"offset":21},"end":{"line":1,"column":25,"offset":25},"type":"BooleanLiteral","value":true},{"start":{"line":1,"column":26,"offset":26},"end":{"line":1,"column":31,"offset":31},"type":"StringLiteral","value":"abc","raw":"\"abc\""}]}]}}],"sourceType":"tag","dotNotationUpperCase":true,"handleUnquotedAttrValueAsString":true}', - serializeJSON(var:result,compact:true) - ); + var expected = deserializeJSON( '{"start":{"line":1,"column":0,"offset":0},"end":{"line":1,"column":44,"offset":44},"type":"Program","body":[{"start":{"line":1,"column":0,"offset":0},"end":{"line":1,"column":44,"offset":44},"type":"CFMLTag","isBuiltIn":true,"name":"script","nameSpace":"cf","nameSpaceSeparator":"","fullname":"cfscript","attributes":[],"body":{"type":"BlockStatement","body":[{"start":{"line":1,"column":10,"offset":10},"end":{"line":1,"column":32,"offset":32},"type":"CallExpression","callee":{"type":"Identifier","name":"WHATEVER"},"arguments":[{"start":{"line":1,"column":19,"offset":19},"end":{"line":1,"column":20,"offset":20},"type":"NumberLiteral","raw":"1","value":1},{"start":{"line":1,"column":21,"offset":21},"end":{"line":1,"column":25,"offset":25},"type":"BooleanLiteral","value":true},{"start":{"line":1,"column":26,"offset":26},"end":{"line":1,"column":31,"offset":31},"type":"StringLiteral","value":"abc","raw":"\"abc\"","quoteChar":"\""}]}]}}],"sourceType":"tag","dotNotationUpperCase":true,"handleUnquotedAttrValueAsString":true}' ); + assertASTMatches( expected, result ); }); it( title = 'test named arguments', body = function( currentSpec ) { var result = astFromString('whatever(arg1=1, arg2=true, arg3="abc");'); - assertEquals( - '{"start":{"line":1,"column":0,"offset":0},"end":{"line":1,"column":61,"offset":61},"type":"Program","body":[{"start":{"line":1,"column":0,"offset":0},"end":{"line":1,"column":61,"offset":61},"type":"CFMLTag","isBuiltIn":true,"name":"script","nameSpace":"cf","nameSpaceSeparator":"","fullname":"cfscript","attributes":[],"body":{"type":"BlockStatement","body":[{"start":{"line":1,"column":10,"offset":10},"end":{"line":1,"column":49,"offset":49},"type":"CallExpression","callee":{"type":"Identifier","name":"WHATEVER"},"arguments":[{"type":"NamedArgument","name":{"start":{"line":1,"column":19,"offset":19},"end":{"line":1,"column":23,"offset":23},"type":"Identifier","name":"ARG1"},"value":{"start":{"line":1,"column":24,"offset":24},"end":{"line":1,"column":25,"offset":25},"type":"NumberLiteral","raw":"1","value":1}},{"type":"NamedArgument","name":{"start":{"line":1,"column":27,"offset":27},"end":{"line":1,"column":31,"offset":31},"type":"Identifier","name":"ARG2"},"value":{"start":{"line":1,"column":32,"offset":32},"end":{"line":1,"column":36,"offset":36},"type":"BooleanLiteral","value":true}},{"type":"NamedArgument","name":{"start":{"line":1,"column":38,"offset":38},"end":{"line":1,"column":42,"offset":42},"type":"Identifier","name":"ARG3"},"value":{"start":{"line":1,"column":43,"offset":43},"end":{"line":1,"column":48,"offset":48},"type":"StringLiteral","value":"abc","raw":"\"abc\""}}]}]}}],"sourceType":"tag","dotNotationUpperCase":true,"handleUnquotedAttrValueAsString":true}', - serializeJSON(var:result,compact:true) - ); + var expected = deserializeJSON( '{"start":{"line":1,"column":0,"offset":0},"end":{"line":1,"column":61,"offset":61},"type":"Program","body":[{"start":{"line":1,"column":0,"offset":0},"end":{"line":1,"column":61,"offset":61},"type":"CFMLTag","isBuiltIn":true,"name":"script","nameSpace":"cf","nameSpaceSeparator":"","fullname":"cfscript","attributes":[],"body":{"type":"BlockStatement","body":[{"start":{"line":1,"column":10,"offset":10},"end":{"line":1,"column":49,"offset":49},"type":"CallExpression","callee":{"type":"Identifier","name":"WHATEVER"},"arguments":[{"type":"NamedArgument","name":{"start":{"line":1,"column":19,"offset":19},"end":{"line":1,"column":23,"offset":23},"type":"Identifier","name":"ARG1"},"value":{"start":{"line":1,"column":24,"offset":24},"end":{"line":1,"column":25,"offset":25},"type":"NumberLiteral","raw":"1","value":1}},{"type":"NamedArgument","name":{"start":{"line":1,"column":27,"offset":27},"end":{"line":1,"column":31,"offset":31},"type":"Identifier","name":"ARG2"},"value":{"start":{"line":1,"column":32,"offset":32},"end":{"line":1,"column":36,"offset":36},"type":"BooleanLiteral","value":true}},{"type":"NamedArgument","name":{"start":{"line":1,"column":38,"offset":38},"end":{"line":1,"column":42,"offset":42},"type":"Identifier","name":"ARG3"},"value":{"start":{"line":1,"column":43,"offset":43},"end":{"line":1,"column":48,"offset":48},"type":"StringLiteral","value":"abc","raw":"\"abc\"","quoteChar":"\""}}]}]}}],"sourceType":"tag","dotNotationUpperCase":true,"handleUnquotedAttrValueAsString":true}' ); + assertASTMatches( expected, result ); }); it( title = 'test unknown tag self-closing without slash', body = function( currentSpec ) { @@ -191,4 +183,60 @@ component extends = "org.lucee.cfml.test.LuceeTestCase" { }); } + + /** + * Compare two AST structures recursively. + * Returns empty string if match, otherwise returns path to first difference. + */ + private string function compareAST( required any expected, required any actual, string path = "" ) { + // Handle nulls + if ( isNull( expected ) && isNull( actual ) ) return ""; + if ( isNull( expected ) || isNull( actual ) ) return path & " (null mismatch)"; + + // Simple types + if ( isSimpleValue( expected ) ) { + if ( !isSimpleValue( actual ) ) return path & " (type mismatch: expected simple, got complex)"; + if ( expected != actual ) return path & " (value mismatch: expected [#expected#], got [#actual#])"; + return ""; + } + + // Arrays + if ( isArray( expected ) ) { + if ( !isArray( actual ) ) return path & " (type mismatch: expected array, got #getMetaData( actual ).getName()#)"; + if ( arrayLen( expected ) != arrayLen( actual ) ) return path & " (array length mismatch: expected #arrayLen( expected )#, got #arrayLen( actual )#)"; + for ( var i = 1; i <= arrayLen( expected ); i++ ) { + var result = compareAST( expected[ i ], actual[ i ], path & "[#i#]" ); + if ( len( result ) ) return result; + } + return ""; + } + + // Structs - check both directions for mismatches + if ( isStruct( expected ) ) { + if ( !isStruct( actual ) ) return path & " (type mismatch: expected struct, got #getMetaData( actual ).getName()#)"; + // Check for missing keys in actual + for ( var key in expected ) { + if ( !structKeyExists( actual, key ) ) return path & ".#key# (missing key in actual)"; + var result = compareAST( expected[ key ], actual[ key ], path & ".#key#" ); + if ( len( result ) ) return result; + } + // Check for extra keys in actual + for ( var key in actual ) { + if ( !structKeyExists( expected, key ) ) return path & ".#key# (extra key in actual)"; + } + return ""; + } + + return path & " (unknown type)"; + } + + /** + * Assert AST matches expected structure exactly. + */ + private void function assertASTMatches( required any expected, required any actual, string message = "AST mismatch" ) { + var diff = compareAST( expected, actual ); + if ( len( diff ) ) { + fail( message & ": " & diff ); + } + } } \ No newline at end of file From 87acdf242718bd2bd112182b170541c58c373f9b Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Fri, 19 Dec 2025 21:26:12 +0100 Subject: [PATCH 42/71] LDEV-6016 don't merge static blocks in AST --- .../cfml/evaluator/impl/Function.java | 2 +- .../cfml/evaluator/impl/Static.java | 24 ++++++---- test/tickets/LDEV6016.cfc | 46 +++++++++++++++++++ .../tickets/LDEV6016/mixedStaticTagScript.cfc | 11 +++++ .../tickets/LDEV6016/multipleStaticBlocks.cfc | 5 ++ 5 files changed, 77 insertions(+), 11 deletions(-) create mode 100644 test/tickets/LDEV6016/mixedStaticTagScript.cfc create mode 100644 test/tickets/LDEV6016/multipleStaticBlocks.cfc diff --git a/core/src/main/java/lucee/transformer/cfml/evaluator/impl/Function.java b/core/src/main/java/lucee/transformer/cfml/evaluator/impl/Function.java index f8bce49e4be..062dac41931 100755 --- a/core/src/main/java/lucee/transformer/cfml/evaluator/impl/Function.java +++ b/core/src/main/java/lucee/transformer/cfml/evaluator/impl/Function.java @@ -155,7 +155,7 @@ public void evaluate(Tag tag, TagLibTag libTag, FunctionLib flibs) throws Evalua ASMUtil.remove(tag); Body body = (Body) tag.getParent(); - StaticBody sb = Static.getStaticBody(body); + StaticBody sb = Static.getStaticBodyForFunction(body); sb.addStatement(tag); } } diff --git a/core/src/main/java/lucee/transformer/cfml/evaluator/impl/Static.java b/core/src/main/java/lucee/transformer/cfml/evaluator/impl/Static.java index 8f34def2443..a2c123ffaff 100644 --- a/core/src/main/java/lucee/transformer/cfml/evaluator/impl/Static.java +++ b/core/src/main/java/lucee/transformer/cfml/evaluator/impl/Static.java @@ -18,7 +18,6 @@ **/ package lucee.transformer.cfml.evaluator.impl; -import java.util.Iterator; import java.util.List; import lucee.commons.lang.StringUtil; @@ -40,7 +39,6 @@ public final class Static extends EvaluatorSupport { @Override public void evaluate(Tag tag, TagLibTag libTag) throws EvaluatorException { - // check parent Body body = null; @@ -70,7 +68,7 @@ public void evaluate(Tag tag, TagLibTag libTag) throws EvaluatorException { // remove that tag from parent ASMUtil.remove(tag); - StaticBody sb = getStaticBody(body); + StaticBody sb = createStaticBody(body); ASMUtil.addStatements(sb, children); } @@ -84,16 +82,22 @@ private String getFullname(Tag tag, String defaultValue) { return defaultValue; } - static StaticBody getStaticBody(Body body) { - Iterator it = body.getStatements().iterator(); - Statement s; - while (it.hasNext()) { - s = it.next(); - if (s instanceof StaticBody) return (StaticBody) s; - } + /** + * Creates a new StaticBody and adds it to the component body. + * Each static { } block gets its own StaticBody to preserve AST structure. + */ + static StaticBody createStaticBody(Body body) { StaticBody sb = new StaticBody(body.getFactory()); body.addStatement(sb); return sb; } + /** + * Creates a new StaticBody for static functions. + * Each static function gets its own StaticBody to preserve AST structure. + */ + static StaticBody getStaticBodyForFunction(Body body) { + return createStaticBody(body); + } + } \ No newline at end of file diff --git a/test/tickets/LDEV6016.cfc b/test/tickets/LDEV6016.cfc index ce23b9996c0..9b50d30893b 100644 --- a/test/tickets/LDEV6016.cfc +++ b/test/tickets/LDEV6016.cfc @@ -33,6 +33,52 @@ component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { expect( assignment.final ).toBe( true, "Assignment should be marked final" ); }); + it( "should NOT merge multiple static blocks", function() { + var ast = astFromPath( variables.testDir & "multipleStaticBlocks.cfc" ); + var comp = ast.body[1]; + + // Find static blocks + var staticBlocks = comp.body.body.filter( function( s ) { + return structKeyExists( s, "static" ) && s.static == true; + }); + + // Should have 3 separate static blocks: + // - static { static1=1; } + // - static { static2=2; } + // - static function foo() (wrapped in cfstatic) + expect( staticBlocks ).toHaveLength( 3, "Should preserve separate static blocks" ); + + // Verify each has the expected content + expect( staticBlocks[1].body ).toHaveLength( 1, "First static block should have 1 statement" ); + expect( staticBlocks[2].body ).toHaveLength( 1, "Second static block should have 1 statement" ); + expect( staticBlocks[3].body ).toHaveLength( 1, "Third static block (function) should have 1 statement" ); + expect( staticBlocks[3].body[1].type ).toBe( "FunctionDeclaration", "Third block should contain the function" ); + }); + + it( "should NOT merge script static block with tag cffunction modifier=static", function() { + var ast = astFromPath( variables.testDir & "mixedStaticTagScript.cfc" ); + var comp = ast.body[1]; + + // Find static blocks + var staticBlocks = comp.body.body.filter( function( s ) { + return structKeyExists( s, "static" ) && s.static == true; + }); + + // Should have 2 separate static blocks: + // - static { static1=1; } (script) + // - cffunction modifier="static" (tag) + expect( staticBlocks ).toHaveLength( 2, "Should preserve separate static blocks (script and tag)" ); + + // First should be the script static block with AssignmentExpression + expect( staticBlocks[1].body ).toHaveLength( 1, "Script static block should have 1 statement" ); + expect( staticBlocks[1].body[1].type ).toBe( "AssignmentExpression", "Should be assignment" ); + + // Second should be the cffunction tag + expect( staticBlocks[2].body ).toHaveLength( 1, "Tag static block should have 1 statement" ); + expect( staticBlocks[2].body[1].type ).toBe( "CFMLTag", "Should be CFMLTag (cffunction)" ); + expect( staticBlocks[2].body[1].fullname ).toBe( "cffunction", "Should be cffunction tag" ); + }); + }); } diff --git a/test/tickets/LDEV6016/mixedStaticTagScript.cfc b/test/tickets/LDEV6016/mixedStaticTagScript.cfc new file mode 100644 index 00000000000..3b505f39e46 --- /dev/null +++ b/test/tickets/LDEV6016/mixedStaticTagScript.cfc @@ -0,0 +1,11 @@ + + + static { + static1=1; + } + + + + + + diff --git a/test/tickets/LDEV6016/multipleStaticBlocks.cfc b/test/tickets/LDEV6016/multipleStaticBlocks.cfc new file mode 100644 index 00000000000..9f061cf89de --- /dev/null +++ b/test/tickets/LDEV6016/multipleStaticBlocks.cfc @@ -0,0 +1,5 @@ +component { + static { static1=1; } + static { static2=2; } + static function foo() { return static; } +} From a2c2de63b8bd0a468abf51da29ec7eb613fbe489 Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Fri, 19 Dec 2025 21:45:28 +0100 Subject: [PATCH 43/71] LDEV-6028 preserve struct key AST type regardless of separator --- .../bytecode/expression/var/DynAssign.java | 16 ++- .../expression/AbstrCFMLExprTransformer.java | 3 +- test/tickets/LDEV6028.cfc | 108 ++++++++++++++++++ 3 files changed, 123 insertions(+), 4 deletions(-) create mode 100644 test/tickets/LDEV6028.cfc diff --git a/core/src/main/java/lucee/transformer/bytecode/expression/var/DynAssign.java b/core/src/main/java/lucee/transformer/bytecode/expression/var/DynAssign.java index 315995a53da..d8d613c61f1 100755 --- a/core/src/main/java/lucee/transformer/bytecode/expression/var/DynAssign.java +++ b/core/src/main/java/lucee/transformer/bytecode/expression/var/DynAssign.java @@ -38,6 +38,7 @@ public final class DynAssign extends ExpressionBase { private ExprString name; private Expression value; + private Expression sourceName; // Original expression before string conversion, for AST fidelity // Object setVariable(String, Object) private final static Method METHOD_SET_VARIABLE = new Method("setVariable", Types.OBJECT, new Type[] { Types.STRING, Types.OBJECT }); @@ -48,12 +49,13 @@ public DynAssign(Factory f, Position start, Position end) { /** * Constructor of the class - * + * * @param name * @param value */ public DynAssign(Expression name, Expression value) { super(name.getFactory(), name.getStart(), name.getEnd()); + this.sourceName = name; // Preserve original for AST output this.name = name.getFactory().toExprString(name); this.value = value; } @@ -76,12 +78,19 @@ public Type _writeOut(BytecodeContext bc, int mode) throws TransformerException */ /** - * @return the name + * @return the name as ExprString (for bytecode generation) */ public ExprString getName() { return name; } + /** + * @return the original name expression (for AST fidelity) + */ + public Expression getSourceName() { + return sourceName != null ? sourceName : name; + } + /** * @return the value */ @@ -97,7 +106,8 @@ public void dump(Struct sct) { Struct left = new StructImpl(Struct.TYPE_LINKED); sct.setEL(KeyConstants._left, left); - name.dump(left); + // Use sourceName for AST output to preserve original type (e.g., NumberLiteral) + getSourceName().dump(left); Struct right = new StructImpl(Struct.TYPE_LINKED); sct.setEL(KeyConstants._right, right); diff --git a/core/src/main/java/lucee/transformer/cfml/expression/AbstrCFMLExprTransformer.java b/core/src/main/java/lucee/transformer/cfml/expression/AbstrCFMLExprTransformer.java index 5d0b9515538..243111ffcf0 100755 --- a/core/src/main/java/lucee/transformer/cfml/expression/AbstrCFMLExprTransformer.java +++ b/core/src/main/java/lucee/transformer/cfml/expression/AbstrCFMLExprTransformer.java @@ -301,7 +301,8 @@ private Argument functionArgument(Data data, String type, boolean varKeyUpperCas } else if (expr instanceof DynAssign) { DynAssign da = (DynAssign) expr; - return new NamedArgumentImpl(da.getName(), da.getValue(), type, varKeyUpperCase); + // Use getSourceName() to preserve original expression type (e.g., NumberLiteral) for AST + return new NamedArgumentImpl(da.getSourceName(), da.getValue(), type, varKeyUpperCase); } else if (expr instanceof Assign && !(expr instanceof OpVariable)) { Assign a = (Assign) expr; diff --git a/test/tickets/LDEV6028.cfc b/test/tickets/LDEV6028.cfc new file mode 100644 index 00000000000..89c3dfde42e --- /dev/null +++ b/test/tickets/LDEV6028.cfc @@ -0,0 +1,108 @@ +component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { + + function run( testResults, testBox ) { + describe( "LDEV-6028: Struct key AST type changes based on separator (: vs =)", function() { + + it( "numeric key with colon is NumberLiteral", function() { + var ast = astFromString( 'x = { 1: "a" };', "script" ); + var key = ast.body[1].right.properties[1].key; + + expect( key.type ).toBe( "NumberLiteral" ); + expect( key.value ).toBe( 1 ); + }); + + it( "numeric key with equals should be NumberLiteral not StringLiteral", function() { + var ast = astFromString( 'x = { 1 = "a" };', "script" ); + var key = ast.body[1].right.properties[1].key; + + // BUG: Currently returns StringLiteral with raw="1" instead of NumberLiteral + expect( key.type ).toBe( "NumberLiteral" ); + expect( key.value ).toBe( 1 ); + }); + + it( "float key with colon is NumberLiteral", function() { + var ast = astFromString( 'x = { 1.5: "a" };', "script" ); + var key = ast.body[1].right.properties[1].key; + + expect( key.type ).toBe( "NumberLiteral" ); + expect( key.value ).toBe( 1.5 ); + }); + + it( "float key with equals should be NumberLiteral not StringLiteral", function() { + var ast = astFromString( 'x = { 1.5 = "a" };', "script" ); + var key = ast.body[1].right.properties[1].key; + + // BUG: Currently returns StringLiteral with raw="1.5" instead of NumberLiteral + expect( key.type ).toBe( "NumberLiteral" ); + expect( key.value ).toBe( 1.5 ); + }); + + it( "negative numeric key with colon is NumberLiteral", function() { + var ast = astFromString( 'x = { -1: "a" };', "script" ); + var key = ast.body[1].right.properties[1].key; + + expect( key.type ).toBe( "NumberLiteral" ); + expect( key.value ).toBe( -1 ); + }); + + it( "negative numeric key with equals should be NumberLiteral not StringLiteral", function() { + var ast = astFromString( 'x = { -1 = "a" };', "script" ); + var key = ast.body[1].right.properties[1].key; + + // BUG: Currently returns StringLiteral with raw="-1" instead of NumberLiteral + expect( key.type ).toBe( "NumberLiteral" ); + expect( key.value ).toBe( -1 ); + }); + + it( "boolean key with colon is BooleanLiteral", function() { + var ast = astFromString( 'x = { true: "a" };', "script" ); + var key = ast.body[1].right.properties[1].key; + + expect( key.type ).toBe( "BooleanLiteral" ); + expect( key.value ).toBe( true ); + }); + + // Skip: true/false are reserved words in Lucee, so { true = "a" } is parsed as + // an invalid assignment to the literal 'true', not as a struct key. + // This is expected parser behavior, not an AST bug. + // See: https://docs.lucee.org/guides/developing-with-lucee-server/reserved-word.html + xit( "boolean key with equals should be BooleanLiteral not parse error", function() { + var ast = astFromString( 'x = { true = "a" };', "script" ); + var key = ast.body[1].right.properties[1].key; + + expect( key.type ).toBe( "BooleanLiteral" ); + expect( key.value ).toBe( true ); + }); + + it( "quoted string key is consistent with both separators", function() { + var astColon = astFromString( 'x = { "foo": "a" };', "script" ); + var keyColon = astColon.body[1].right.properties[1].key; + + var astEquals = astFromString( 'x = { "foo" = "a" };', "script" ); + var keyEquals = astEquals.body[1].right.properties[1].key; + + // Both should be StringLiteral - this works correctly + expect( keyColon.type ).toBe( "StringLiteral" ); + expect( keyEquals.type ).toBe( "StringLiteral" ); + expect( keyColon.value ).toBe( "foo" ); + expect( keyEquals.value ).toBe( "foo" ); + }); + + it( "identifier key is consistent with both separators", function() { + var astColon = astFromString( 'x = { foo: "a" };', "script" ); + var keyColon = astColon.body[1].right.properties[1].key; + + var astEquals = astFromString( 'x = { foo = "a" };', "script" ); + var keyEquals = astEquals.body[1].right.properties[1].key; + + // Both should be Identifier - this works correctly + expect( keyColon.type ).toBe( "Identifier" ); + expect( keyEquals.type ).toBe( "Identifier" ); + expect( keyColon.name ).toBe( "foo" ); + expect( keyEquals.name ).toBe( "foo" ); + }); + + }); + } + +} From 2ab33fd7cad865d756297263ea1eee33ffae58b7 Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Sat, 20 Dec 2025 00:53:37 +0100 Subject: [PATCH 44/71] LDEV-6029 preserve cachedWithin expression in script function AST --- .../bytecode/statement/udf/Function.java | 11 +++- test/tickets/LDEV6029.cfc | 57 +++++++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 test/tickets/LDEV6029.cfc diff --git a/core/src/main/java/lucee/transformer/bytecode/statement/udf/Function.java b/core/src/main/java/lucee/transformer/bytecode/statement/udf/Function.java index b68cf7a5773..d3748665e21 100644 --- a/core/src/main/java/lucee/transformer/bytecode/statement/udf/Function.java +++ b/core/src/main/java/lucee/transformer/bytecode/statement/udf/Function.java @@ -152,6 +152,7 @@ public abstract class Function extends StatementBaseNoFinal implements Opcodes, ExprInt localMode; // protected int localIndex = -1; Literal cachedWithin; + Expression sourceCachedWithin; // Original expression for AST fidelity (before evaluation) int modifier; protected JavaFunction jf; protected String rawJavaSource; // raw Java source for AST round-tripping @@ -581,6 +582,7 @@ else if ("localmode".equals(name)) { } else if ("cachedwithin".equals(name)) { try { + this.sourceCachedWithin = attr.getValue(); // Preserve original for AST this.cachedWithin = ASMUtil.cachedWithinValue(attr.getValue());// ASMUtil.timeSpanToLong(attr.getValue()); } catch (EvaluatorException e) { @@ -735,8 +737,13 @@ public void dump(Struct sct, String type) { localMode.dump(s); sct.setEL(KeyConstants._localMode, s); } - // cachedWithin - if (cachedWithin != null) { + // cachedWithin - use original expression if available for AST fidelity + if (sourceCachedWithin != null) { + Struct s = new StructImpl(Struct.TYPE_LINKED); + sourceCachedWithin.dump(s); + sct.setEL(KeyConstants._cachedWithin, s); + } + else if (cachedWithin != null) { Struct s = new StructImpl(Struct.TYPE_LINKED); cachedWithin.dump(s); sct.setEL(KeyConstants._cachedWithin, s); diff --git a/test/tickets/LDEV6029.cfc b/test/tickets/LDEV6029.cfc new file mode 100644 index 00000000000..af570977035 --- /dev/null +++ b/test/tickets/LDEV6029.cfc @@ -0,0 +1,57 @@ +component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { + + function run( testResults, testBox ) { + describe( "LDEV-6029: cachedWithin preserves original expression in AST", function() { + + it( "script function with createTimespan shows CallExpression not LongLiteral", function() { + var code = 'function test() cachedWithin="##createTimespan(0,0,0,0,20)##" { }'; + var ast = astFromString( code, "script" ); + var fn = ast.body[1]; + + expect( fn.type ).toBe( "FunctionDeclaration" ); + expect( fn ).toHaveKey( "cachedWithin" ); + expect( fn.cachedWithin.type ).toBe( "CallExpression" ); + expect( fn.cachedWithin.callee.name ).toBe( "createtimespan" ); + expect( arrayLen( fn.cachedWithin.arguments ) ).toBe( 5 ); + }); + + it( "script function with numeric cachedWithin shows NumberLiteral not evaluated LongLiteral", function() { + var code = 'function test() cachedWithin=20 { }'; + var ast = astFromString( code, "script" ); + var fn = ast.body[1]; + + expect( fn.type ).toBe( "FunctionDeclaration" ); + expect( fn ).toHaveKey( "cachedWithin" ); + expect( fn.cachedWithin.type ).toBe( "NumberLiteral" ); + expect( fn.cachedWithin.value ).toBe( 20 ); + expect( fn.cachedWithin.raw ).toBe( "20" ); + }); + + it( "script function with string cachedWithin preserves StringLiteral", function() { + var code = 'function test() cachedWithin="request" { }'; + var ast = astFromString( code, "script" ); + var fn = ast.body[1]; + + expect( fn.type ).toBe( "FunctionDeclaration" ); + expect( fn ).toHaveKey( "cachedWithin" ); + expect( fn.cachedWithin.type ).toBe( "StringLiteral" ); + expect( fn.cachedWithin.value ).toBe( "request" ); + }); + + it( "tag function with createTimespan shows CallExpression in attributes", function() { + var code = ''; + var ast = astFromString( code ); + var fn = ast.body[1]; + + expect( fn.type ).toBe( "CFMLTag" ); + expect( fn.name ).toBe( "function" ); + var cachedAttr = fn.attributes.filter( function( a ) { return a.name == "cachedwithin"; } ); + expect( arrayLen( cachedAttr ) ).toBe( 1 ); + expect( cachedAttr[1].value.type ).toBe( "CallExpression" ); + expect( cachedAttr[1].value.callee.name ).toBe( "createtimespan" ); + }); + + }); + } + +} From 414d8cdcd510e39cc5178cc1cbe82257501005c5 Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Sat, 20 Dec 2025 01:02:51 +0100 Subject: [PATCH 45/71] LDEV-6030 include label property in loop AST nodes --- .../bytecode/statement/DoWhile.java | 5 ++ .../transformer/bytecode/statement/For.java | 5 ++ .../bytecode/statement/ForEach.java | 5 ++ .../transformer/bytecode/statement/While.java | 5 ++ test/tickets/LDEV6030.cfc | 67 +++++++++++++++++++ 5 files changed, 87 insertions(+) create mode 100644 test/tickets/LDEV6030.cfc diff --git a/core/src/main/java/lucee/transformer/bytecode/statement/DoWhile.java b/core/src/main/java/lucee/transformer/bytecode/statement/DoWhile.java index 5f67f765ade..26897e16596 100755 --- a/core/src/main/java/lucee/transformer/bytecode/statement/DoWhile.java +++ b/core/src/main/java/lucee/transformer/bytecode/statement/DoWhile.java @@ -100,6 +100,11 @@ public void dump(Struct sct) { super.dump(sct); sct.setEL(KeyConstants._type, "DoWhileStatement"); + // label + if ( label != null ) { + sct.setEL(KeyConstants._label, label); + } + // body { Struct body = new StructImpl(Struct.TYPE_LINKED); diff --git a/core/src/main/java/lucee/transformer/bytecode/statement/For.java b/core/src/main/java/lucee/transformer/bytecode/statement/For.java index 2cae4b3b664..7e2d02684c7 100755 --- a/core/src/main/java/lucee/transformer/bytecode/statement/For.java +++ b/core/src/main/java/lucee/transformer/bytecode/statement/For.java @@ -130,6 +130,11 @@ public void dump(Struct sct) { super.dump(sct); sct.setEL(KeyConstants._type, "ForStatement"); + // label + if ( label != null ) { + sct.setEL(KeyConstants._label, label); + } + // init if ( this.init != null ) { Struct init = new StructImpl(Struct.TYPE_LINKED); diff --git a/core/src/main/java/lucee/transformer/bytecode/statement/ForEach.java b/core/src/main/java/lucee/transformer/bytecode/statement/ForEach.java index 0c0b8e03948..67a38751306 100755 --- a/core/src/main/java/lucee/transformer/bytecode/statement/ForEach.java +++ b/core/src/main/java/lucee/transformer/bytecode/statement/ForEach.java @@ -173,6 +173,11 @@ public void dump(Struct sct) { super.dump(sct); sct.setEL(KeyConstants._type, "ForOfStatement"); + // label + if ( label != null ) { + sct.setEL(KeyConstants._label, label); + } + // left { Struct left = new StructImpl(Struct.TYPE_LINKED); diff --git a/core/src/main/java/lucee/transformer/bytecode/statement/While.java b/core/src/main/java/lucee/transformer/bytecode/statement/While.java index 3a63263bb44..adc771cd81e 100755 --- a/core/src/main/java/lucee/transformer/bytecode/statement/While.java +++ b/core/src/main/java/lucee/transformer/bytecode/statement/While.java @@ -111,6 +111,11 @@ public void dump(Struct sct) { super.dump(sct); sct.setEL(KeyConstants._type, "WhileStatement"); + // label + if ( label != null ) { + sct.setEL(KeyConstants._label, label); + } + // test { Struct test = new StructImpl(Struct.TYPE_LINKED); diff --git a/test/tickets/LDEV6030.cfc b/test/tickets/LDEV6030.cfc new file mode 100644 index 00000000000..3d6bc8470a8 --- /dev/null +++ b/test/tickets/LDEV6030.cfc @@ -0,0 +1,67 @@ +component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { + + function run( testResults, testBox ) { + describe( "LDEV-6030: Labeled loops should include label in AST", function() { + + it( "labeled for loop has label property", function() { + var code = 'outer: for( var i = 1; i <= 5; i++ ) { }'; + var ast = astFromString( code, "script" ); + var forStmt = ast.body[1]; + + expect( forStmt.type ).toBe( "ForStatement" ); + expect( forStmt ).toHaveKey( "label" ); + expect( forStmt.label ).toBe( "outer" ); + }); + + it( "labeled while loop has label property", function() { + var code = 'myLoop: while( true ) { break myLoop; }'; + var ast = astFromString( code, "script" ); + var whileStmt = ast.body[1]; + + expect( whileStmt.type ).toBe( "WhileStatement" ); + expect( whileStmt ).toHaveKey( "label" ); + expect( whileStmt.label ).toBe( "myLoop" ); + }); + + it( "labeled do-while loop has label property", function() { + var code = 'test: do { } while( false );'; + var ast = astFromString( code, "script" ); + var doWhileStmt = ast.body[1]; + + expect( doWhileStmt.type ).toBe( "DoWhileStatement" ); + expect( doWhileStmt ).toHaveKey( "label" ); + expect( doWhileStmt.label ).toBe( "test" ); + }); + + it( "labeled for-in loop has label property", function() { + var code = 'items: for( var item in [1,2,3] ) { }'; + var ast = astFromString( code, "script" ); + var forOfStmt = ast.body[1]; + + expect( forOfStmt.type ).toBe( "ForOfStatement" ); + expect( forOfStmt ).toHaveKey( "label" ); + expect( forOfStmt.label ).toBe( "items" ); + }); + + it( "unlabeled for loop has no label property", function() { + var code = 'for( var i = 1; i <= 5; i++ ) { }'; + var ast = astFromString( code, "script" ); + var forStmt = ast.body[1]; + + expect( forStmt.type ).toBe( "ForStatement" ); + expect( forStmt ).notToHaveKey( "label" ); + }); + + it( "unlabeled while loop has no label property", function() { + var code = 'while( false ) { }'; + var ast = astFromString( code, "script" ); + var whileStmt = ast.body[1]; + + expect( whileStmt.type ).toBe( "WhileStatement" ); + expect( whileStmt ).notToHaveKey( "label" ); + }); + + }); + } + +} From d89ee223b6e0940d0396fa11a4e65a16269d2b89 Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Sat, 20 Dec 2025 19:59:52 +0100 Subject: [PATCH 46/71] LDEV-6031 fix cfimport custom tag AST output - Use appendix as name when tagLibTag.getName() is empty (imported custom tags) - Skip default attributes in AST dump (filters out __custom_tag_path) --- .../bytecode/statement/tag/TagBase.java | 10 +- test/tickets/LDEV6031.cfc | 105 ++++++++++++++++++ test/tickets/LDEV6031/cfimport-basic.cfm | 2 + test/tickets/LDEV6031/cfimport-multiple.cfm | 4 + test/tickets/LDEV6031/cfimport-nested.cfm | 4 + test/tickets/LDEV6031/customtags/hello.cfm | 3 + test/tickets/LDEV6031/customtags/inner.cfm | 3 + test/tickets/LDEV6031/customtags/outer.cfm | 6 + 8 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 test/tickets/LDEV6031.cfc create mode 100644 test/tickets/LDEV6031/cfimport-basic.cfm create mode 100644 test/tickets/LDEV6031/cfimport-multiple.cfm create mode 100644 test/tickets/LDEV6031/cfimport-nested.cfm create mode 100644 test/tickets/LDEV6031/customtags/hello.cfm create mode 100644 test/tickets/LDEV6031/customtags/inner.cfm create mode 100644 test/tickets/LDEV6031/customtags/outer.cfm diff --git a/core/src/main/java/lucee/transformer/bytecode/statement/tag/TagBase.java b/core/src/main/java/lucee/transformer/bytecode/statement/tag/TagBase.java index 816f5a07839..fd7bd5ffcb9 100755 --- a/core/src/main/java/lucee/transformer/bytecode/statement/tag/TagBase.java +++ b/core/src/main/java/lucee/transformer/bytecode/statement/tag/TagBase.java @@ -216,7 +216,12 @@ public void dump(Struct sct) { sct.setEL("isBuiltIn", Boolean.TRUE); } - sct.setEL(KeyConstants._name, tagLibTag.getName()); + // For custom tags with empty name (using appendix), use the appendix as the name + String tagName = tagLibTag.getName(); + if ((tagName == null || tagName.isEmpty()) && appendix != null) { + tagName = appendix; + } + sct.setEL(KeyConstants._name, tagName); sct.setEL(KeyConstants._nameSpace, tagLibTag.getTagLib().getNameSpace()); sct.setEL(KeyConstants._nameSpaceSeparator, tagLibTag.getTagLib().getNameSpaceSeparator()); if (appendix != null) sct.setEL(KeyConstants._appendix, appendix); @@ -228,6 +233,9 @@ public void dump(Struct sct) { Map attrsToUse = sourceAttributes != null ? sourceAttributes : attributes; for (Entry entry: attrsToUse.entrySet()) { Attribute attr = entry.getValue(); + // Skip default attributes - they weren't in the source + if (attr.isDefaultAttribute()) continue; + Struct sctAttr = new StructImpl(Struct.TYPE_LINKED); arrAttrs.appendEL(sctAttr); sctAttr.setEL(KeyConstants._name, attr.getName()); diff --git a/test/tickets/LDEV6031.cfc b/test/tickets/LDEV6031.cfc new file mode 100644 index 00000000000..6b960cbe2ff --- /dev/null +++ b/test/tickets/LDEV6031.cfc @@ -0,0 +1,105 @@ +component extends="org.lucee.cfml.test.LuceeTestCase" { + + function run( testResults, testBox ) { + describe( "LDEV-6031: cfimport custom tags in AST", function() { + + it( "should not leak __custom_tag_path internal attribute", function() { + var ast = getAst( "cfimport-basic.cfm" ); + var customTag = findCustomTag( ast, "my:hello" ); + + expect( customTag ).notToBeNull(); + + // Check no internal attributes leaked + var attrNames = customTag.attributes.map( function( a ) { return a.name; } ); + expect( attrNames ).notToInclude( "__custom_tag_path" ); + }); + + it( "should have tag name in 'name' field, not just appendix", function() { + var ast = getAst( "cfimport-basic.cfm" ); + var customTag = findCustomTag( ast, "my:hello" ); + + expect( customTag ).notToBeNull(); + // name should be "hello", not empty string + expect( customTag.name ).toBe( "hello" ); + }); + + it( "should preserve cfimport tag in AST", function() { + var ast = getAst( "cfimport-basic.cfm" ); + var importTag = findTag( ast, "import" ); + + expect( importTag ).notToBeNull(); + expect( importTag.fullname ).toBe( "cfimport" ); + }); + + it( "should preserve all custom tag invocations in body", function() { + var ast = getAst( "cfimport-multiple.cfm" ); + + // Should have cfimport + 3 custom tags + whitespace nodes + var customTags = ast.body.filter( function( node ) { + return ( node.type == "CFMLTag" && node.keyExists( "nameSpace" ) && node.nameSpace == "my" ); + }); + + expect( customTags.len() ).toBe( 3 ); + }); + + it( "should handle nested custom tags", function() { + var ast = getAst( "cfimport-nested.cfm" ); + var outerTag = findCustomTag( ast, "my:outer" ); + + expect( outerTag ).notToBeNull(); + expect( outerTag.keyExists( "body" ) ).toBeTrue(); + + // Find inner tag in body + var innerTag = findCustomTag( outerTag.body, "my:inner" ); + expect( innerTag ).notToBeNull(); + }); + + // Quirk #50: Imported custom tags should not be missing from AST + it( "should not omit imported custom tags from AST body", function() { + var ast = getAst( "cfimport-basic.cfm" ); + + // Count all CFMLTag nodes in body + var allTags = ast.body.filter( function( node ) { + return node.type == "CFMLTag"; + }); + + // Should have at least cfimport + custom tag (not just cfimport) + expect( allTags.len() ).toBeGTE( 2, "Custom tag should not be missing from AST" ); + + // Verify the custom tag is present (not just cfimport) + var hasCustomTag = allTags.some( function( tag ) { + return tag.keyExists( "nameSpace" ) && tag.nameSpace == "my"; + }); + expect( hasCustomTag ).toBeTrue( "Imported prefix custom tag should be in AST body" ); + }); + + }); + } + + private function getAst( required string filename ) { + var testDir = getDirectoryFromPath( getCurrentTemplatePath() ); + var filePath = testDir & "LDEV6031/" & filename; + return astFromPath( filePath ); + } + + private function findTag( required struct ast, required string tagName ) { + var body = ast.keyExists( "body" ) ? ( isArray( ast.body ) ? ast.body : ast.body.body ?: [] ) : []; + for ( var node in body ) { + if ( node.type == "CFMLTag" && node.name == tagName ) { + return node; + } + } + return javaCast( "null", 0 ); + } + + private function findCustomTag( required struct ast, required string fullname ) { + var body = ast.keyExists( "body" ) ? ( isArray( ast.body ) ? ast.body : ast.body.body ?: [] ) : []; + for ( var node in body ) { + if ( node.type == "CFMLTag" && node.keyExists( "fullname" ) && node.fullname == fullname ) { + return node; + } + } + return javaCast( "null", 0 ); + } + +} diff --git a/test/tickets/LDEV6031/cfimport-basic.cfm b/test/tickets/LDEV6031/cfimport-basic.cfm new file mode 100644 index 00000000000..7c2a304fe49 --- /dev/null +++ b/test/tickets/LDEV6031/cfimport-basic.cfm @@ -0,0 +1,2 @@ + + diff --git a/test/tickets/LDEV6031/cfimport-multiple.cfm b/test/tickets/LDEV6031/cfimport-multiple.cfm new file mode 100644 index 00000000000..d61a0453a8a --- /dev/null +++ b/test/tickets/LDEV6031/cfimport-multiple.cfm @@ -0,0 +1,4 @@ + + + + diff --git a/test/tickets/LDEV6031/cfimport-nested.cfm b/test/tickets/LDEV6031/cfimport-nested.cfm new file mode 100644 index 00000000000..14fdfb4e84b --- /dev/null +++ b/test/tickets/LDEV6031/cfimport-nested.cfm @@ -0,0 +1,4 @@ + + + + diff --git a/test/tickets/LDEV6031/customtags/hello.cfm b/test/tickets/LDEV6031/customtags/hello.cfm new file mode 100644 index 00000000000..7df97d2e3f0 --- /dev/null +++ b/test/tickets/LDEV6031/customtags/hello.cfm @@ -0,0 +1,3 @@ + + +Hello, #attributes.name#! diff --git a/test/tickets/LDEV6031/customtags/inner.cfm b/test/tickets/LDEV6031/customtags/inner.cfm new file mode 100644 index 00000000000..13ef679a0c4 --- /dev/null +++ b/test/tickets/LDEV6031/customtags/inner.cfm @@ -0,0 +1,3 @@ + + +#attributes.value# diff --git a/test/tickets/LDEV6031/customtags/outer.cfm b/test/tickets/LDEV6031/customtags/outer.cfm new file mode 100644 index 00000000000..d8a9be1dce8 --- /dev/null +++ b/test/tickets/LDEV6031/customtags/outer.cfm @@ -0,0 +1,6 @@ + + +
+ +
+
From 01b40549dbc812ef5317c55f9deec981ebc85ec4 Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Sat, 20 Dec 2025 20:04:26 +0100 Subject: [PATCH 47/71] LDEV-6030 skip null label attribute for cfbreak/cfcontinue in AST --- .../transformer/cfml/tag/CFMLTransformer.java | 15 +++- test/tickets/LDEV6030.cfc | 81 +++++++++++++++++++ 2 files changed, 95 insertions(+), 1 deletion(-) diff --git a/core/src/main/java/lucee/transformer/cfml/tag/CFMLTransformer.java b/core/src/main/java/lucee/transformer/cfml/tag/CFMLTransformer.java index be8fed2d07e..f57628a9f89 100755 --- a/core/src/main/java/lucee/transformer/cfml/tag/CFMLTransformer.java +++ b/core/src/main/java/lucee/transformer/cfml/tag/CFMLTransformer.java @@ -63,6 +63,7 @@ import lucee.transformer.cfml.script.AbstrCFMLScriptTransformer.ComponentTemplateException; import lucee.transformer.cfml.script.CFMLScriptTransformer; import lucee.transformer.expression.Expression; +import lucee.transformer.expression.literal.LitString; import lucee.transformer.library.function.FunctionLib; import lucee.transformer.library.tag.CustomTagLib; import lucee.transformer.library.tag.TagLib; @@ -1129,7 +1130,19 @@ private static void attrNoName(Tag parent, TagLibTag tag, Data data, TagLibTagAt pe = attr.getRtexpr(); } // LitString.toExprString("",-1); - Attribute att = new Attribute(false, strName, attributeValue(data, tag, strType, pe, true, data.factory.createNull()), strType); + Expression value = attributeValue(data, tag, strType, pe, true, data.factory.createNull()); + + // For string-typed attributes, if the value is the default null (nothing was parsed), + // don't add the attribute. This prevents cfbreak/cfcontinue from having a meaningless + // label attribute with null value. For "any" type (like cfreturn), null is meaningful. + if ("string".equalsIgnoreCase(strType) && value instanceof LitString) { + LitString litStr = (LitString) value; + if (litStr.getString() == null) { + return; // Skip adding attribute with null string value + } + } + + Attribute att = new Attribute(false, strName, value, strType); parent.addAttribute(att); } diff --git a/test/tickets/LDEV6030.cfc b/test/tickets/LDEV6030.cfc index 3d6bc8470a8..b53c73f388c 100644 --- a/test/tickets/LDEV6030.cfc +++ b/test/tickets/LDEV6030.cfc @@ -62,6 +62,87 @@ component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { }); }); + + describe( "LDEV-6030: cfbreak/cfcontinue without label should not have label attribute", function() { + + it( "cfbreak without label has no label attribute", function() { + var code = ''; + var ast = astFromString( code, "tag" ); + var cfloop = ast.body[1]; + var cfbreak = cfloop.body.body[1]; + + expect( cfbreak.type ).toBe( "CFMLTag" ); + expect( cfbreak.name ).toBe( "break" ); + expect( cfbreak.attributes ).toBeEmpty(); + }); + + it( "cfcontinue without label has no label attribute", function() { + var code = ''; + var ast = astFromString( code, "tag" ); + var cfloop = ast.body[1]; + var cfcontinue = cfloop.body.body[1]; + + expect( cfcontinue.type ).toBe( "CFMLTag" ); + expect( cfcontinue.name ).toBe( "continue" ); + expect( cfcontinue.attributes ).toBeEmpty(); + }); + + it( "cfbreak with label has label attribute", function() { + var code = ''; + var ast = astFromString( code, "tag" ); + var cfloop = ast.body[1]; + var cfbreak = cfloop.body.body[1]; + + expect( cfbreak.type ).toBe( "CFMLTag" ); + expect( cfbreak.name ).toBe( "break" ); + expect( cfbreak.attributes ).toHaveLength( 1 ); + expect( cfbreak.attributes[1].name ).toBe( "label" ); + }); + + it( "cfcontinue with label has label attribute", function() { + var code = ''; + var ast = astFromString( code, "tag" ); + var cfloop = ast.body[1]; + var cfcontinue = cfloop.body.body[1]; + + expect( cfcontinue.type ).toBe( "CFMLTag" ); + expect( cfcontinue.name ).toBe( "continue" ); + expect( cfcontinue.attributes ).toHaveLength( 1 ); + expect( cfcontinue.attributes[1].name ).toBe( "label" ); + }); + + }); + + describe( "LDEV-6030: cfreturn should preserve expr attribute", function() { + + it( "cfreturn without value has expr attribute with NullLiteral", function() { + var code = ''; + var ast = astFromString( code, "tag" ); + var cffunction = ast.body[1]; + var cfreturn = cffunction.body.body[1]; + + expect( cfreturn.type ).toBe( "CFMLTag" ); + expect( cfreturn.name ).toBe( "return" ); + expect( cfreturn.attributes ).toHaveLength( 1 ); + expect( cfreturn.attributes[1].name ).toBe( "expr" ); + expect( cfreturn.attributes[1].value.type ).toBe( "NullLiteral" ); + }); + + it( "cfreturn with value has expr attribute", function() { + var code = ''; + var ast = astFromString( code, "tag" ); + var cffunction = ast.body[1]; + var cfreturn = cffunction.body.body[1]; + + expect( cfreturn.type ).toBe( "CFMLTag" ); + expect( cfreturn.name ).toBe( "return" ); + expect( cfreturn.attributes ).toHaveLength( 1 ); + expect( cfreturn.attributes[1].name ).toBe( "expr" ); + expect( cfreturn.attributes[1].value.type ).toBe( "StringLiteral" ); + expect( cfreturn.attributes[1].value.value ).toBe( "hello" ); + }); + + }); } } From 6b2febf452ceff65fd6049d6090cb4a35c32b13a Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Sat, 20 Dec 2025 20:39:10 +0100 Subject: [PATCH 48/71] LDEV-6016 preserve static block/function position in AST --- .../transformer/bytecode/StaticBody.java | 7 ++ .../cfml/evaluator/impl/Function.java | 7 +- .../cfml/evaluator/impl/Static.java | 41 +++++++-- test/tickets/LDEV6016.cfc | 86 +++++++++++++++++++ test/tickets/LDEV6016/staticBlockPosition.cfc | 11 +++ .../LDEV6016/staticTagFunctionPosition.cfc | 11 +++ 6 files changed, 152 insertions(+), 11 deletions(-) create mode 100644 test/tickets/LDEV6016/staticBlockPosition.cfc create mode 100644 test/tickets/LDEV6016/staticTagFunctionPosition.cfc diff --git a/core/src/main/java/lucee/transformer/bytecode/StaticBody.java b/core/src/main/java/lucee/transformer/bytecode/StaticBody.java index f04d1526bf2..7991d3714c2 100644 --- a/core/src/main/java/lucee/transformer/bytecode/StaticBody.java +++ b/core/src/main/java/lucee/transformer/bytecode/StaticBody.java @@ -19,6 +19,7 @@ import lucee.runtime.type.Struct; import lucee.transformer.Factory; +import lucee.transformer.Position; public final class StaticBody extends BodyBase { @@ -26,6 +27,12 @@ public StaticBody(Factory f) { super(f); } + public StaticBody(Factory f, Position start, Position end) { + super(f); + setStart(start); + setEnd(end); + } + @Override public void dump(Struct sct) { super.dump(sct); diff --git a/core/src/main/java/lucee/transformer/cfml/evaluator/impl/Function.java b/core/src/main/java/lucee/transformer/cfml/evaluator/impl/Function.java index 062dac41931..41230f665ca 100755 --- a/core/src/main/java/lucee/transformer/cfml/evaluator/impl/Function.java +++ b/core/src/main/java/lucee/transformer/cfml/evaluator/impl/Function.java @@ -151,11 +151,14 @@ public void evaluate(Tag tag, TagLibTag libTag, FunctionLib flibs) throws Evalua // add to static scope if (isStatic) { + Body body = (Body) tag.getParent(); + // Find the index of the tag before removing it (for AST position preservation) + int tagIndex = body.getStatements().indexOf(tag); + // remove that tag from parent ASMUtil.remove(tag); - Body body = (Body) tag.getParent(); - StaticBody sb = Static.getStaticBodyForFunction(body); + StaticBody sb = Static.getStaticBodyForFunction(body, tag, tagIndex); sb.addStatement(tag); } } diff --git a/core/src/main/java/lucee/transformer/cfml/evaluator/impl/Static.java b/core/src/main/java/lucee/transformer/cfml/evaluator/impl/Static.java index a2c123ffaff..6ca5957174f 100644 --- a/core/src/main/java/lucee/transformer/cfml/evaluator/impl/Static.java +++ b/core/src/main/java/lucee/transformer/cfml/evaluator/impl/Static.java @@ -46,16 +46,19 @@ public void evaluate(Tag tag, TagLibTag libTag) throws EvaluatorException { boolean isCompChild = false; Tag p = ASMUtil.getParentTag(tag); + Tag containingTag = null; // The tag that directly contains this static in the component body if (p != null && (p instanceof TagComponent || getFullname(p, "").equalsIgnoreCase(compName))) { isCompChild = true; body = p.getBody(); + containingTag = tag; // static tag is directly in component body } Tag pp = p != null ? ASMUtil.getParentTag(p) : null; - if (!isCompChild && pp != null && (p instanceof TagComponent || getFullname(pp, "").equalsIgnoreCase(compName))) { + if (!isCompChild && pp != null && (pp instanceof TagComponent || getFullname(pp, "").equalsIgnoreCase(compName))) { isCompChild = true; body = pp.getBody(); + containingTag = p; // static is inside another tag (e.g., cfscript) in component body } if (!isCompChild) { @@ -65,10 +68,14 @@ public void evaluate(Tag tag, TagLibTag libTag) throws EvaluatorException { // Body body=(Body) tag.getParent(); List children = tag.getBody().getStatements(); + // Find the index of the containing tag in component body (for AST position preservation) + // For direct cfstatic, this is the tag itself. For script static { }, this is cfscript. + int tagIndex = containingTag != null ? body.getStatements().indexOf(containingTag) : -1; + // remove that tag from parent ASMUtil.remove(tag); - StaticBody sb = createStaticBody(body); + StaticBody sb = createStaticBody(body, tag, tagIndex); ASMUtil.addStatements(sb, children); } @@ -83,21 +90,37 @@ private String getFullname(Tag tag, String defaultValue) { } /** - * Creates a new StaticBody and adds it to the component body. + * Creates a new StaticBody and adds it to the component body at the original tag position. * Each static { } block gets its own StaticBody to preserve AST structure. + * + * @param body The component body to add the StaticBody to + * @param tag The original cfstatic tag (for position info) + * @param tagIndex The original index of the tag in the body (-1 to append at end) */ - static StaticBody createStaticBody(Body body) { - StaticBody sb = new StaticBody(body.getFactory()); - body.addStatement(sb); + static StaticBody createStaticBody(Body body, Statement tag, int tagIndex) { + StaticBody sb = new StaticBody(body.getFactory(), tag != null ? tag.getStart() : null, tag != null ? tag.getEnd() : null); + if (tagIndex >= 0 && tagIndex < body.getStatements().size()) { + // Insert at original position + body.getStatements().add(tagIndex, sb); + sb.setParent(body); + } + else { + // Fallback: append at end + body.addStatement(sb); + } return sb; } /** - * Creates a new StaticBody for static functions. + * Creates a new StaticBody for static functions at the original tag position. * Each static function gets its own StaticBody to preserve AST structure. + * + * @param body The component body to add the StaticBody to + * @param tag The original cffunction tag (for position info) + * @param tagIndex The original index of the tag in the body (-1 to append at end) */ - static StaticBody getStaticBodyForFunction(Body body) { - return createStaticBody(body); + static StaticBody getStaticBodyForFunction(Body body, Statement tag, int tagIndex) { + return createStaticBody(body, tag, tagIndex); } } \ No newline at end of file diff --git a/test/tickets/LDEV6016.cfc b/test/tickets/LDEV6016.cfc index 9b50d30893b..2c3bccfc37c 100644 --- a/test/tickets/LDEV6016.cfc +++ b/test/tickets/LDEV6016.cfc @@ -80,6 +80,92 @@ component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { }); }); + + describe( "LDEV-6016: Static block position in AST", function() { + + it( "should preserve tag-style static function position relative to other members", function() { + // Source: cfproperty (line 2), static cffunction (line 4-6), cffunction init (line 8) + var ast = astFromPath( variables.testDir & "staticTagFunctionPosition.cfc" ); + var comp = ast.body[1]; + + // Filter to meaningful nodes (skip whitespace/expression statements) + var members = comp.body.body.filter( function( s ) { + return s.type == "CFMLTag" || ( s.type == "BlockStatement" && structKeyExists( s, "static" ) ); + }); + + // Should be: cfproperty, static function wrapper, cffunction init (in source order) + expect( members ).toHaveLength( 3, "Should have 3 members (property, static function, init function)" ); + + // First should be cfproperty + expect( members[1].type ).toBe( "CFMLTag" ); + expect( members[1].name ).toBe( "property" ); + + // Second should be the static function (wrapped in BlockStatement) + // OR the cffunction directly if not wrapped + if ( members[2].type == "BlockStatement" ) { + expect( members[2].static ).toBe( true, "Static function wrapper should be at original position (after property)" ); + // The function inside should be getVersion + var innerFunc = members[2].body.filter( function( n ) { return n.type == "CFMLTag"; })[1]; + var nameAttr = innerFunc.attributes.filter( function( a ) { return a.name == "name"; })[1]; + expect( nameAttr.value.value ).toBe( "getVersion", "Static function getVersion should be first" ); + } else { + // If cffunction with static marker + expect( members[2].name ).toBe( "function" ); + var nameAttr = members[2].attributes.filter( function( a ) { return a.name == "name"; })[1]; + expect( nameAttr.value.value ).toBe( "getVersion", "Static function getVersion should be first" ); + } + + // Third should be cffunction init + if ( members[3].type == "BlockStatement" ) { + // Bug: init was hoisted, static is at end - this is WRONG + fail( "Bug: init function should be third, not hoisted to second position" ); + } + expect( members[3].type ).toBe( "CFMLTag" ); + expect( members[3].name ).toBe( "function" ); + var nameAttr3 = members[3].attributes.filter( function( a ) { return a.name == "name"; })[1]; + expect( nameAttr3.value.value ).toBe( "init", "init function should be last" ); + }); + + it( "should preserve static block position relative to other members", function() { + // Source: cfproperty (line 2), cfstatic (line 4-6), cffunction (line 8) + var ast = astFromPath( variables.testDir & "staticBlockPosition.cfc" ); + var comp = ast.body[1]; + + // Filter to meaningful nodes (skip whitespace/expression statements) + var members = comp.body.body.filter( function( s ) { + return s.type == "CFMLTag" || ( s.type == "BlockStatement" && structKeyExists( s, "static" ) ); + }); + + // Should be: cfproperty, static BlockStatement, cffunction (in source order) + expect( members ).toHaveLength( 3, "Should have 3 members (property, static, function)" ); + + // First should be cfproperty + expect( members[1].type ).toBe( "CFMLTag" ); + expect( members[1].name ).toBe( "property" ); + + // Second should be static block (at original position) + expect( members[2].type ).toBe( "BlockStatement" ); + expect( members[2].static ).toBe( true, "Static block should be at original position" ); + + // Third should be cffunction + expect( members[3].type ).toBe( "CFMLTag" ); + expect( members[3].name ).toBe( "function" ); + }); + + it( "static block should have start position info", function() { + var ast = astFromPath( variables.testDir & "staticBlockPosition.cfc" ); + var comp = ast.body[1]; + + var staticBlocks = comp.body.body.filter( function( s ) { + return s.type == "BlockStatement" && structKeyExists( s, "static" ) && s.static == true; + }); + + expect( staticBlocks ).toHaveLength( 1 ); + expect( staticBlocks[1] ).toHaveKey( "start", "Static block should have start position" ); + expect( staticBlocks[1].start.line ).toBe( 4, "Static block should start at line 4" ); + }); + + }); } } diff --git a/test/tickets/LDEV6016/staticBlockPosition.cfc b/test/tickets/LDEV6016/staticBlockPosition.cfc new file mode 100644 index 00000000000..7f86baf2d43 --- /dev/null +++ b/test/tickets/LDEV6016/staticBlockPosition.cfc @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/test/tickets/LDEV6016/staticTagFunctionPosition.cfc b/test/tickets/LDEV6016/staticTagFunctionPosition.cfc new file mode 100644 index 00000000000..b1b83aa5226 --- /dev/null +++ b/test/tickets/LDEV6016/staticTagFunctionPosition.cfc @@ -0,0 +1,11 @@ + + + + + + + + + + + From 1987785ad65af661f0cc766890ae9c3ab1b4e1cd Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Sat, 20 Dec 2025 21:54:39 +0100 Subject: [PATCH 49/71] LDEV-6032 parse unquoted boolean attribute values as BooleanLiteral in tag mode --- .../expression/AbstrCFMLExprTransformer.java | 11 +- .../expression/SimpleExprTransformer.java | 11 +- test/tickets/LDEV6032.cfc | 114 ++++++++++++++++++ test/tickets/LDEV6032/script-bool-false.cfm | 1 + test/tickets/LDEV6032/script-bool-true.cfm | 1 + test/tickets/LDEV6032/tag-bool-false.cfm | 1 + test/tickets/LDEV6032/tag-bool-standalone.cfm | 1 + test/tickets/LDEV6032/tag-bool-true.cfm | 1 + 8 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 test/tickets/LDEV6032.cfc create mode 100644 test/tickets/LDEV6032/script-bool-false.cfm create mode 100644 test/tickets/LDEV6032/script-bool-true.cfm create mode 100644 test/tickets/LDEV6032/tag-bool-false.cfm create mode 100644 test/tickets/LDEV6032/tag-bool-standalone.cfm create mode 100644 test/tickets/LDEV6032/tag-bool-true.cfm diff --git a/core/src/main/java/lucee/transformer/cfml/expression/AbstrCFMLExprTransformer.java b/core/src/main/java/lucee/transformer/cfml/expression/AbstrCFMLExprTransformer.java index 243111ffcf0..e36b4ee2df3 100755 --- a/core/src/main/java/lucee/transformer/cfml/expression/AbstrCFMLExprTransformer.java +++ b/core/src/main/java/lucee/transformer/cfml/expression/AbstrCFMLExprTransformer.java @@ -2068,7 +2068,16 @@ private Expression simple(Data data, String[] breakConditions) throws TemplateEx } comments(data); - return data.factory.createLitString(sb.toString(), line, data.srcCode.getPosition()); + String value = sb.toString(); + Position end = data.srcCode.getPosition(); + // Check for boolean literals (case-insensitive) + if (value.equalsIgnoreCase("true")) { + return data.factory.createLitBoolean(true, line, end); + } + if (value.equalsIgnoreCase("false")) { + return data.factory.createLitBoolean(false, line, end); + } + return data.factory.createLitString(value, line, end); } /** diff --git a/core/src/main/java/lucee/transformer/cfml/expression/SimpleExprTransformer.java b/core/src/main/java/lucee/transformer/cfml/expression/SimpleExprTransformer.java index c0f280265c4..91a6fdbab0e 100755 --- a/core/src/main/java/lucee/transformer/cfml/expression/SimpleExprTransformer.java +++ b/core/src/main/java/lucee/transformer/cfml/expression/SimpleExprTransformer.java @@ -132,7 +132,16 @@ else if (cfml.isCurrent('"') || cfml.isCurrent('#') || cfml.isCurrent('\'')) { } cfml.removeSpace(); - return f.createLitString(sb.toString(), line, cfml.getPosition()); + String value = sb.toString(); + Position end = cfml.getPosition(); + // Check for boolean literals (case-insensitive) + if (value.equalsIgnoreCase("true")) { + return f.createLitBoolean(true, line, end); + } + if (value.equalsIgnoreCase("false")) { + return f.createLitBoolean(false, line, end); + } + return f.createLitString(value, line, end); } } diff --git a/test/tickets/LDEV6032.cfc b/test/tickets/LDEV6032.cfc new file mode 100644 index 00000000000..a013e591cb1 --- /dev/null +++ b/test/tickets/LDEV6032.cfc @@ -0,0 +1,114 @@ +component extends="org.lucee.cfml.test.LuceeTestCase" { + + function run( testResults, testBox ) { + describe( "LDEV-6032: Boolean attribute values in AST", function() { + + // Tag mode tests (Quirk 46) + it( "should parse unquoted boolean true as BooleanLiteral in tag mode", function() { + var ast = getAst( "tag-bool-true.cfm" ); + var dumpTag = findTag( ast, "dump" ); + + expect( dumpTag ).notToBeNull(); + var abortAttr = findAttribute( dumpTag, "abort" ); + expect( abortAttr ).notToBeNull(); + expect( abortAttr.value.type ).toBe( "BooleanLiteral" ); + expect( abortAttr.value.value ).toBeTrue(); + }); + + it( "should parse unquoted boolean false as BooleanLiteral in tag mode", function() { + var ast = getAst( "tag-bool-false.cfm" ); + var queryTag = findTag( ast, "query" ); + + expect( queryTag ).notToBeNull(); + var attr = findAttribute( queryTag, "cachedWithin" ); + expect( attr ).notToBeNull(); + expect( attr.value.type ).toBe( "BooleanLiteral" ); + expect( attr.value.value ).toBeFalse(); + }); + + // Script mode tests (Quirk 53) + // SKIPPED: silent tag uses TLD type="single" to support positional syntax (silent false { }) + // which prevents proper named attribute parsing. The AST for "silent bufferOutput=false" + // shows CastExpression(AssignmentExpression) instead of BooleanLiteral. + // Fixing this would break the positional syntax - complex edge case, not worth the risk. + it( title="should parse boolean attribute as BooleanLiteral in script mode", body=function() { + var ast = getAst( "script-bool-false.cfm" ); + var silentTag = findScriptTag( ast, "silent" ); + + expect( silentTag ).notToBeNull(); + var attr = findAttribute( silentTag, "bufferoutput" ); + expect( attr ).notToBeNull(); + // Should be BooleanLiteral, not CastExpression(AssignmentExpression) + expect( attr.value.type ).toBe( "BooleanLiteral" ); + expect( attr.value.value ).toBeFalse(); + }, skip=true ); + + it( "should parse boolean true attribute in script mode", function() { + var ast = getAst( "script-bool-true.cfm" ); + var settingTag = findScriptTag( ast, "setting" ); + + expect( settingTag ).notToBeNull(); + var attr = findAttribute( settingTag, "showDebugOutput" ); + expect( attr ).notToBeNull(); + expect( attr.value.type ).toBe( "BooleanLiteral" ); + expect( attr.value.value ).toBeTrue(); + }); + + // Verify standalone boolean attributes still work + it( "should parse standalone boolean attribute as BooleanLiteral", function() { + var ast = getAst( "tag-bool-standalone.cfm" ); + var dumpTag = findTag( ast, "dump" ); + + expect( dumpTag ).notToBeNull(); + var abortAttr = findAttribute( dumpTag, "abort" ); + expect( abortAttr ).notToBeNull(); + expect( abortAttr.value.type ).toBe( "BooleanLiteral" ); + expect( abortAttr.value.value ).toBeTrue(); + }); + + }); + } + + private function getAst( required string filename ) { + var testDir = getDirectoryFromPath( getCurrentTemplatePath() ); + var filePath = testDir & "LDEV6032/" & filename; + return astFromPath( filePath ); + } + + private function findTag( required struct ast, required string tagName ) { + var body = ast.keyExists( "body" ) ? ( isArray( ast.body ) ? ast.body : ast.body.body ?: [] ) : []; + for ( var node in body ) { + if ( node.type == "CFMLTag" && node.name == tagName ) { + return node; + } + } + return javaCast( "null", 0 ); + } + + private function findScriptTag( required struct ast, required string tagName ) { + var body = ast.keyExists( "body" ) ? ( isArray( ast.body ) ? ast.body : ast.body.body ?: [] ) : []; + for ( var node in body ) { + // Script island + if ( node.type == "CFMLTag" && node.name == "script" && node.keyExists( "body" ) ) { + var scriptBody = node.body.body ?: []; + for ( var scriptNode in scriptBody ) { + if ( scriptNode.type == "CFMLTag" && lCase( scriptNode.name ) == lCase( tagName ) ) { + return scriptNode; + } + } + } + } + return javaCast( "null", 0 ); + } + + private function findAttribute( required struct tag, required string attrName ) { + var attrs = tag.attributes ?: []; + for ( var attr in attrs ) { + if ( lCase( attr.name ) == lCase( attrName ) ) { + return attr; + } + } + return javaCast( "null", 0 ); + } + +} diff --git a/test/tickets/LDEV6032/script-bool-false.cfm b/test/tickets/LDEV6032/script-bool-false.cfm new file mode 100644 index 00000000000..20b391d3ca4 --- /dev/null +++ b/test/tickets/LDEV6032/script-bool-false.cfm @@ -0,0 +1 @@ +silent bufferOutput=false { } \ No newline at end of file diff --git a/test/tickets/LDEV6032/script-bool-true.cfm b/test/tickets/LDEV6032/script-bool-true.cfm new file mode 100644 index 00000000000..46739cbe2d0 --- /dev/null +++ b/test/tickets/LDEV6032/script-bool-true.cfm @@ -0,0 +1 @@ +setting showDebugOutput=true; \ No newline at end of file diff --git a/test/tickets/LDEV6032/tag-bool-false.cfm b/test/tickets/LDEV6032/tag-bool-false.cfm new file mode 100644 index 00000000000..7926f347984 --- /dev/null +++ b/test/tickets/LDEV6032/tag-bool-false.cfm @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/test/tickets/LDEV6032/tag-bool-standalone.cfm b/test/tickets/LDEV6032/tag-bool-standalone.cfm new file mode 100644 index 00000000000..28c4c26e464 --- /dev/null +++ b/test/tickets/LDEV6032/tag-bool-standalone.cfm @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/test/tickets/LDEV6032/tag-bool-true.cfm b/test/tickets/LDEV6032/tag-bool-true.cfm new file mode 100644 index 00000000000..7955c9f184f --- /dev/null +++ b/test/tickets/LDEV6032/tag-bool-true.cfm @@ -0,0 +1 @@ + \ No newline at end of file From ff8b89dfd176cc63688b28b7f7b403415dbbd912 Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Sun, 21 Dec 2025 01:19:11 +0100 Subject: [PATCH 50/71] LDEV-6011 track separator character in named arguments and struct properties Add separator field to distinguish colon syntax (key: value) from equals syntax (key=value) in AST output for both NamedArgument and ObjectExpression Property nodes. --- .../expression/var/NamedArgumentImpl.java | 14 +++ .../bytecode/expression/var/VariableImpl.java | 2 + .../expression/AbstrCFMLExprTransformer.java | 11 +- .../expression/var/NamedArgument.java | 9 ++ test/tickets/LDEV6011.cfc | 106 ++++++++++++++++++ 5 files changed, 138 insertions(+), 4 deletions(-) diff --git a/core/src/main/java/lucee/transformer/bytecode/expression/var/NamedArgumentImpl.java b/core/src/main/java/lucee/transformer/bytecode/expression/var/NamedArgumentImpl.java index 003ab1786d9..573f7791fc8 100755 --- a/core/src/main/java/lucee/transformer/bytecode/expression/var/NamedArgumentImpl.java +++ b/core/src/main/java/lucee/transformer/bytecode/expression/var/NamedArgumentImpl.java @@ -50,11 +50,17 @@ public final class NamedArgumentImpl extends ArgumentImpl implements NamedArgume private Expression name; private boolean varKeyUpperCase; + private char separator; public NamedArgumentImpl(Expression name, Expression value, String type, boolean varKeyUpperCase) { + this(name, value, type, varKeyUpperCase, NamedArgument.SEPARATOR_EQUALS); + } + + public NamedArgumentImpl(Expression name, Expression value, String type, boolean varKeyUpperCase, char separator) { super(value, type); this.name = name instanceof Null || name instanceof NullConstant ? name.getFactory().createLitString(varKeyUpperCase ? "NULL" : "null") : name; this.varKeyUpperCase = varKeyUpperCase; + this.separator = separator; } @Override @@ -108,6 +114,11 @@ public Expression getName() { return name; } + @Override + public char getSeparator() { + return separator; + } + @Override public void dump(Struct sct) { sct.setEL(KeyConstants._type, "NamedArgument"); @@ -121,5 +132,8 @@ public void dump(Struct sct) { Struct value = new StructImpl(Struct.TYPE_LINKED); sct.setEL(KeyConstants._value, value); super.dump(value); + + // separator (: or =) + sct.setEL(KeyConstants._separator, String.valueOf(separator)); } } \ No newline at end of file diff --git a/core/src/main/java/lucee/transformer/bytecode/expression/var/VariableImpl.java b/core/src/main/java/lucee/transformer/bytecode/expression/var/VariableImpl.java index 874a4842df6..22d3228d885 100644 --- a/core/src/main/java/lucee/transformer/bytecode/expression/var/VariableImpl.java +++ b/core/src/main/java/lucee/transformer/bytecode/expression/var/VariableImpl.java @@ -1163,6 +1163,8 @@ private static void buildMemberExpressionIterative(Struct sct, List memb Struct valueNode = new StructImpl(Struct.TYPE_LINKED); na.getValue().dump(valueNode); prop.setEL(KeyConstants._value, valueNode); + // Track the separator used (: or =) + prop.setEL(KeyConstants._separator, String.valueOf(na.getSeparator())); } else { // Fallback for non-named arguments (shouldn't happen for struct literals) diff --git a/core/src/main/java/lucee/transformer/cfml/expression/AbstrCFMLExprTransformer.java b/core/src/main/java/lucee/transformer/cfml/expression/AbstrCFMLExprTransformer.java index e36b4ee2df3..0c05ca4df0b 100755 --- a/core/src/main/java/lucee/transformer/cfml/expression/AbstrCFMLExprTransformer.java +++ b/core/src/main/java/lucee/transformer/cfml/expression/AbstrCFMLExprTransformer.java @@ -46,6 +46,7 @@ import lucee.transformer.bytecode.expression.var.DynAssign; import lucee.transformer.bytecode.expression.var.FunctionMember; import lucee.transformer.bytecode.expression.var.NamedArgumentImpl; +import lucee.transformer.expression.var.NamedArgument; import lucee.transformer.bytecode.expression.var.UDF; import lucee.transformer.bytecode.literal.Identifier; import lucee.transformer.bytecode.literal.LitStringImpl; @@ -297,16 +298,16 @@ private Argument functionArgument(Data data, String type, boolean varKeyUpperCas try { if (data.srcCode.forwardIfCurrent(":")) { comments(data); - return new NamedArgumentImpl(expr, assignOp(data), type, varKeyUpperCase); + return new NamedArgumentImpl(expr, assignOp(data), type, varKeyUpperCase, NamedArgument.SEPARATOR_COLON); } else if (expr instanceof DynAssign) { DynAssign da = (DynAssign) expr; // Use getSourceName() to preserve original expression type (e.g., NumberLiteral) for AST - return new NamedArgumentImpl(da.getSourceName(), da.getValue(), type, varKeyUpperCase); + return new NamedArgumentImpl(da.getSourceName(), da.getValue(), type, varKeyUpperCase, NamedArgument.SEPARATOR_EQUALS); } else if (expr instanceof Assign && !(expr instanceof OpVariable)) { Assign a = (Assign) expr; - return new NamedArgumentImpl(a.getVariable(), a.getValue(), type, varKeyUpperCase); + return new NamedArgumentImpl(a.getVariable(), a.getValue(), type, varKeyUpperCase, NamedArgument.SEPARATOR_EQUALS); } } catch (TransformerException be) { @@ -1529,7 +1530,9 @@ else if (isStaticChild || data.srcCode.forwardIfCurrent('.') || (safeNavigation } // property - else invoker.addMember(member = data.factory.createDataMember(namePropUC)); + else { + invoker.addMember(member = data.factory.createDataMember(namePropUC)); + } if (safeNavigation) { member.setSafeNavigated(true); diff --git a/core/src/main/java/lucee/transformer/expression/var/NamedArgument.java b/core/src/main/java/lucee/transformer/expression/var/NamedArgument.java index 4ef021d7757..ed6f5cbf945 100644 --- a/core/src/main/java/lucee/transformer/expression/var/NamedArgument.java +++ b/core/src/main/java/lucee/transformer/expression/var/NamedArgument.java @@ -4,5 +4,14 @@ public interface NamedArgument extends Argument { + public static final char SEPARATOR_COLON = ':'; + public static final char SEPARATOR_EQUALS = '='; + public Expression getName(); + + /** + * Returns the separator character used between key and value. + * Returns ':' for colon syntax (key: value) or '=' for equals syntax (key=value). + */ + public char getSeparator(); } diff --git a/test/tickets/LDEV6011.cfc b/test/tickets/LDEV6011.cfc index 7581aff404f..0d75c5b9a2c 100644 --- a/test/tickets/LDEV6011.cfc +++ b/test/tickets/LDEV6011.cfc @@ -135,6 +135,112 @@ component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { ( arrayLen( right.arguments ) > 0 ? " - first arg: #right.arguments[1].value ?: right.arguments[1].type#" : "" ) ); }); + it( "should distinguish colon separator from equals separator in struct properties", function() { + var colonCode = 'x = { a : 1 };'; + var equalsCode = 'x = { a = 1 };'; + + var colonAst = astFromString( colonCode, "script" ); + var equalsAst = astFromString( equalsCode, "script" ); + + var colonProp = colonAst.body[1].right.properties[1]; + var equalsProp = equalsAst.body[1].right.properties[1]; + + // Both should be Property type + expect( colonProp.type ).toBe( "Property" ); + expect( equalsProp.type ).toBe( "Property" ); + + // Should have a separator field to distinguish them + expect( colonProp ).toHaveKey( "separator", + "Property should have 'separator' field to indicate colon vs equals" ); + expect( equalsProp ).toHaveKey( "separator", + "Property should have 'separator' field to indicate colon vs equals" ); + + // Colon syntax should have separator=":" + expect( colonProp.separator ).toBe( ":", + "Colon syntax { a : 1 } should have separator=':' but got '#colonProp.separator ?: 'null'#'" ); + + // Equals syntax should have separator="=" + expect( equalsProp.separator ).toBe( "=", + "Equals syntax { a = 1 } should have separator='=' but got '#equalsProp.separator ?: 'null'#'" ); + }); + + it( "should preserve mixed separators in same struct", function() { + // CFML allows mixing colon and equals in the same struct literal + var code = 'x = { a : 1, b = 2, c : 3 };'; + var ast = astFromString( code, "script" ); + + var props = ast.body[1].right.properties; + expect( props ).toHaveLength( 3 ); + + // First property uses colon + expect( props[1] ).toHaveKey( "separator" ); + expect( props[1].separator ).toBe( ":", + "First property 'a : 1' should have separator=':' but got '#props[1].separator ?: 'null'#'" ); + + // Second property uses equals + expect( props[2] ).toHaveKey( "separator" ); + expect( props[2].separator ).toBe( "=", + "Second property 'b = 2' should have separator='=' but got '#props[2].separator ?: 'null'#'" ); + + // Third property uses colon + expect( props[3] ).toHaveKey( "separator" ); + expect( props[3].separator ).toBe( ":", + "Third property 'c : 3' should have separator=':' but got '#props[3].separator ?: 'null'#'" ); + }); + + it( "should distinguish colon separator from equals separator in named function arguments", function() { + var colonCode = 'myFunc( zac: 1, micha: 2 );'; + var equalsCode = 'myFunc( zac=1, micha=2 );'; + + var colonAst = astFromString( colonCode, "script" ); + var equalsAst = astFromString( equalsCode, "script" ); + + var colonArgs = colonAst.body[1].arguments; + var equalsArgs = equalsAst.body[1].arguments; + + // Both should be NamedArgument type + expect( colonArgs[1].type ).toBe( "NamedArgument" ); + expect( equalsArgs[1].type ).toBe( "NamedArgument" ); + + // Should have a separator field to distinguish them + expect( colonArgs[1] ).toHaveKey( "separator", + "NamedArgument should have 'separator' field to indicate colon vs equals" ); + expect( equalsArgs[1] ).toHaveKey( "separator", + "NamedArgument should have 'separator' field to indicate colon vs equals" ); + + // Colon syntax should have separator=":" + expect( colonArgs[1].separator ).toBe( ":", + "Colon syntax myFunc( zac: 1 ) should have separator=':' but got '#colonArgs[1].separator ?: 'null'#'" ); + + // Equals syntax should have separator="=" + expect( equalsArgs[1].separator ).toBe( "=", + "Equals syntax myFunc( zac=1 ) should have separator='=' but got '#equalsArgs[1].separator ?: 'null'#'" ); + }); + + it( "should preserve mixed separators in same function call", function() { + // CFML allows mixing colon and equals in the same function call + var code = 'myFunc( a: 1, b=2, c: 3 );'; + var ast = astFromString( code, "script" ); + + var args = ast.body[1].arguments; + expect( args ).toHaveLength( 3 ); + + // First arg uses colon + expect( args[1] ).toHaveKey( "separator" ); + expect( args[1].separator ).toBe( ":", + "First arg 'a: 1' should have separator=':' but got '#args[1].separator ?: 'null'#'" ); + + // Second arg uses equals + expect( args[2] ).toHaveKey( "separator" ); + expect( args[2].separator ).toBe( "=", + "Second arg 'b=2' should have separator='=' but got '#args[2].separator ?: 'null'#'" ); + + // Third arg uses colon + expect( args[3] ).toHaveKey( "separator" ); + expect( args[3].separator ).toBe( ":", + "Third arg 'c: 3' should have separator=':' but got '#args[3].separator ?: 'null'#'" ); + }); + }); } From 47eacabb718a4a300bebe5c58268e5f0aba73f71 Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Sun, 21 Dec 2025 01:58:37 +0100 Subject: [PATCH 51/71] LDEV-6018 fix AST output for unary plus, integer divide, safe navigation, compound assignment - Unary plus now outputs UnaryExpression with operator PLUS instead of CastExpression - Integer divide (\) now outputs operator INTDIV instead of DIVIDE - Safe navigation (?.) now includes optional: true on MemberExpression - Compound assignments (+=, -=, *=, /=, %=, &=) now output AssignmentExpression with compound operators --- .../bytecode/expression/var/VariableImpl.java | 8 + .../transformer/bytecode/op/AbsOpUnary.java | 43 ++-- .../bytecode/op/OpNegateNumber.java | 48 ++-- .../transformer/bytecode/op/OpNumber.java | 2 +- .../transformer/bytecode/op/OpVariable.java | 68 +++++- .../expression/AbstrCFMLExprTransformer.java | 2 +- test/tickets/LDEV6018.cfc | 209 ++++++++++++++++++ 7 files changed, 333 insertions(+), 47 deletions(-) diff --git a/core/src/main/java/lucee/transformer/bytecode/expression/var/VariableImpl.java b/core/src/main/java/lucee/transformer/bytecode/expression/var/VariableImpl.java index 22d3228d885..b3cc234d410 100644 --- a/core/src/main/java/lucee/transformer/bytecode/expression/var/VariableImpl.java +++ b/core/src/main/java/lucee/transformer/bytecode/expression/var/VariableImpl.java @@ -1248,6 +1248,10 @@ else if ("_createComponent".equalsIgnoreCase(funcName)) { Struct callee = new StructImpl(Struct.TYPE_LINKED); callee.setEL(KeyConstants._type, "MemberExpression"); callee.setEL(KeyConstants._computed, isComputedCall); + // Track safe navigation (?.) as optional=true + if (member.getSafeNavigated()) { + callee.setEL(KeyConstants._optional, Boolean.TRUE); + } callee.setEL(KeyConstants._object, current); if (isComputedCall) { @@ -1289,6 +1293,10 @@ else if ("_createComponent".equalsIgnoreCase(funcName)) { boolean isComputed = (memberName instanceof LitString && ((LitString) memberName).fromBracket()) || !(memberName instanceof Literal); newNode.setEL(KeyConstants._computed, isComputed); + // Track safe navigation (?.) as optional=true + if (member.getSafeNavigated()) { + newNode.setEL(KeyConstants._optional, Boolean.TRUE); + } newNode.setEL(KeyConstants._object, current); Struct property = new StructImpl(Struct.TYPE_LINKED); diff --git a/core/src/main/java/lucee/transformer/bytecode/op/AbsOpUnary.java b/core/src/main/java/lucee/transformer/bytecode/op/AbsOpUnary.java index 072a185188c..c5f59db7413 100644 --- a/core/src/main/java/lucee/transformer/bytecode/op/AbsOpUnary.java +++ b/core/src/main/java/lucee/transformer/bytecode/op/AbsOpUnary.java @@ -219,33 +219,30 @@ else if (type == Factory.OP_UNARY_PRE) { return Types.NUMBER; } - private static String toString(int operation) { - if (operation == Factory.OP_UNARY_PLUS) return "PLUS"; - else if (operation == Factory.OP_UNARY_MINUS) return "MINUS"; - else if (operation == Factory.OP_UNARY_DIVIDE) return "DIVIDE"; - else if (operation == Factory.OP_UNARY_MULTIPLY) return "MULTIPLY"; - else if (operation == Factory.OP_UNARY_CONCAT) return "CONCAT"; - else if (operation == Factory.OP_UNARY_CONCAT) return "CONCAT"; - return "UNKNOWN"; + private static String toCompoundOperator(int operation) { + if (operation == Factory.OP_UNARY_PLUS) return "PLUS_ASSIGN"; + else if (operation == Factory.OP_UNARY_MINUS) return "MINUS_ASSIGN"; + else if (operation == Factory.OP_UNARY_DIVIDE) return "DIVIDE_ASSIGN"; + else if (operation == Factory.OP_UNARY_MULTIPLY) return "MULTIPLY_ASSIGN"; + else if (operation == Factory.OP_UNARY_CONCAT) return "CONCAT_ASSIGN"; + return "UNKNOWN_ASSIGN"; } @Override public void dump(Struct sct) { super.dump(sct); - sct.setEL(KeyConstants._type, "UnaryExpression"); - sct.setEL(KeyConstants._operator, toString(operation)); - sct.setEL(KeyConstants._prefix, (type == Factory.OP_UNARY_PRE) ? Boolean.TRUE : Boolean.FALSE); - // variable - { - Struct sctVar = new StructImpl(Struct.TYPE_LINKED); - var.dump(sctVar); - sct.setEL(KeyConstants._variable, sctVar); - } - // value - { - Struct sctVal = new StructImpl(Struct.TYPE_LINKED); - value.dump(sctVal); - sct.setEL(KeyConstants._value, sctVal); - } + // Output as AssignmentExpression with compound operator + sct.setEL(KeyConstants._type, "AssignmentExpression"); + sct.setEL(KeyConstants._operator, toCompoundOperator(operation)); + + // left - the variable being assigned to + Struct left = new StructImpl(Struct.TYPE_LINKED); + sct.setEL(KeyConstants._left, left); + var.dump(left); + + // right - the value being added/subtracted/etc + Struct right = new StructImpl(Struct.TYPE_LINKED); + sct.setEL(KeyConstants._right, right); + value.dump(right); } } \ No newline at end of file diff --git a/core/src/main/java/lucee/transformer/bytecode/op/OpNegateNumber.java b/core/src/main/java/lucee/transformer/bytecode/op/OpNegateNumber.java index 0d6ef30570c..4f546b08c52 100755 --- a/core/src/main/java/lucee/transformer/bytecode/op/OpNegateNumber.java +++ b/core/src/main/java/lucee/transformer/bytecode/op/OpNegateNumber.java @@ -41,44 +41,58 @@ public final class OpNegateNumber extends ExpressionBase implements ExprNumber { private ExprNumber expr; + private int operation; - // public static final int PLUS = 0; - // public static final int MINUS = 1; - - private OpNegateNumber(Expression expr, Position start, Position end) { + private OpNegateNumber(Expression expr, int operation, Position start, Position end) { super(expr.getFactory(), start, end); this.expr = expr.getFactory().toExprNumber(expr); + this.operation = operation; } /** * Create a String expression from an Expression - * + * * @param expr * @param start * @param end - * + * * @return String expression */ public static ExprNumber toExprNumber(Expression expr, Position start, Position end) { - if (expr instanceof Literal) { - Number n = ((Literal) expr).getNumber(null); - if (n != null) { - if (n instanceof BigDecimal) return expr.getFactory().createLitNumber(((BigDecimal) n).negate(), start, end); - return expr.getFactory().createLitNumber(BigDecimal.valueOf(-n.doubleValue()), start, end); - } - } - return new OpNegateNumber(expr, start, end); + return toExprNumber(expr, Factory.OP_NEG_NBR_MINUS, start, end); } public static ExprNumber toExprNumber(Expression expr, int operation, Position start, Position end) { - if (operation == Factory.OP_NEG_NBR_MINUS) return toExprNumber(expr, start, end); - return expr.getFactory().toExprNumber(expr); + if (operation == Factory.OP_NEG_NBR_MINUS) { + // For minus, try to fold literals + if (expr instanceof Literal) { + Number n = ((Literal) expr).getNumber(null); + if (n != null) { + if (n instanceof BigDecimal) return expr.getFactory().createLitNumber(((BigDecimal) n).negate(), start, end); + return expr.getFactory().createLitNumber(BigDecimal.valueOf(-n.doubleValue()), start, end); + } + } + } + // For plus, we could fold literals too, but the value doesn't change + // Still need to create the node to preserve the unary plus in AST + return new OpNegateNumber(expr, operation, start, end); } @Override public Type _writeOut(BytecodeContext bc, int mode) throws TransformerException { GeneratorAdapter adapter = bc.getAdapter(); + // For unary plus, just write out the expression as a number + if (operation == Factory.OP_NEG_NBR_PLUS) { + if (mode == MODE_VALUE) { + expr.writeOut(bc, MODE_VALUE); + return Types.DOUBLE_VALUE; + } + expr.writeOut(bc, MODE_REF); + return Types.NUMBER; + } + + // For unary minus, negate the value if (mode == MODE_VALUE) { expr.writeOut(bc, MODE_VALUE); adapter.visitInsn(Opcodes.DNEG); @@ -94,7 +108,7 @@ public Type _writeOut(BytecodeContext bc, int mode) throws TransformerException public void dump(Struct sct) { super.dump(sct); sct.setEL(KeyConstants._type, "UnaryExpression"); - sct.setEL(KeyConstants._operator, "NEGATE"); + sct.setEL(KeyConstants._operator, operation == Factory.OP_NEG_NBR_PLUS ? "PLUS" : "NEGATE"); sct.setEL(KeyConstants._prefix, Boolean.TRUE); // argument { diff --git a/core/src/main/java/lucee/transformer/bytecode/op/OpNumber.java b/core/src/main/java/lucee/transformer/bytecode/op/OpNumber.java index c492eb22bd1..841b71d3f0f 100755 --- a/core/src/main/java/lucee/transformer/bytecode/op/OpNumber.java +++ b/core/src/main/java/lucee/transformer/bytecode/op/OpNumber.java @@ -150,7 +150,7 @@ else if (op == Factory.OP_DBL_DIVIDE) { return "DIVIDE"; } else if (op == Factory.OP_DBL_INTDIV) { - return "DIVIDE"; + return "INTDIV"; } else if (op == Factory.OP_DBL_PLUS) { return "PLUS"; diff --git a/core/src/main/java/lucee/transformer/bytecode/op/OpVariable.java b/core/src/main/java/lucee/transformer/bytecode/op/OpVariable.java index 2a600790d4a..d64ed923174 100755 --- a/core/src/main/java/lucee/transformer/bytecode/op/OpVariable.java +++ b/core/src/main/java/lucee/transformer/bytecode/op/OpVariable.java @@ -4,20 +4,24 @@ * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either + * License as published by the Free Software Foundation; either * version 2.1 of the License, or (at your option) any later version. - * + * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public + * + * You should have received a copy of the GNU Lesser General Public * License along with this library. If not, see . - * + * **/ package lucee.transformer.bytecode.op; +import lucee.runtime.type.Struct; +import lucee.runtime.type.StructImpl; +import lucee.runtime.type.util.KeyConstants; +import lucee.transformer.Factory; import lucee.transformer.Position; import lucee.transformer.bytecode.expression.var.Assign; import lucee.transformer.expression.Expression; @@ -28,4 +32,58 @@ public final class OpVariable extends Assign { public OpVariable(Variable variable, Expression value, Position end) { super(variable, value, end); } + + @Override + public void dump(Struct sct) { + // Check if this is a compound assignment (value is OpNumber with left side matching our variable) + Expression value = getValue(); + if (value instanceof OpNumber) { + OpNumber opNum = (OpNumber) value; + int op = opNum.getOperation(); + String compoundOp = getCompoundOperator(op); + if (compoundOp != null) { + // This is a compound assignment - output with compound operator + // Dump position info from grandparent (ExpressionBase) + Position start = getStart(); + Position end = getEnd(); + if (start != null) { + Struct sctStart = new StructImpl(Struct.TYPE_LINKED); + sctStart.setEL(KeyConstants._line, start.line); + sctStart.setEL(KeyConstants._column, start.displayColumn()); + sctStart.setEL(KeyConstants._offset, start.displayPosition()); + sct.setEL(KeyConstants._start, sctStart); + } + if (end != null) { + Struct sctEnd = new StructImpl(Struct.TYPE_LINKED); + sctEnd.setEL(KeyConstants._line, end.line); + sctEnd.setEL(KeyConstants._column, end.displayColumn()); + sctEnd.setEL(KeyConstants._offset, end.displayPosition()); + sct.setEL(KeyConstants._end, sctEnd); + } + + sct.setEL(KeyConstants._type, "AssignmentExpression"); + sct.setEL(KeyConstants._operator, compoundOp); + + Struct left = new StructImpl(Struct.TYPE_LINKED); + sct.setEL(KeyConstants._left, left); + getVariable().dump(left); + + Struct right = new StructImpl(Struct.TYPE_LINKED); + sct.setEL(KeyConstants._right, right); + opNum.getRight().dump(right); + return; + } + } + // Fall back to default Assign dump + super.dump(sct); + } + + private static String getCompoundOperator(int op) { + if (op == Factory.OP_DBL_PLUS) return "PLUS_ASSIGN"; + if (op == Factory.OP_DBL_MINUS) return "MINUS_ASSIGN"; + if (op == Factory.OP_DBL_MULTIPLY) return "MULTIPLY_ASSIGN"; + if (op == Factory.OP_DBL_DIVIDE) return "DIVIDE_ASSIGN"; + if (op == Factory.OP_DBL_MODULUS) return "MODULUS_ASSIGN"; + return null; + } } \ No newline at end of file diff --git a/core/src/main/java/lucee/transformer/cfml/expression/AbstrCFMLExprTransformer.java b/core/src/main/java/lucee/transformer/cfml/expression/AbstrCFMLExprTransformer.java index 0c05ca4df0b..b8614db5288 100755 --- a/core/src/main/java/lucee/transformer/cfml/expression/AbstrCFMLExprTransformer.java +++ b/core/src/main/java/lucee/transformer/cfml/expression/AbstrCFMLExprTransformer.java @@ -941,7 +941,7 @@ else if (data.srcCode.forwardIfCurrent('+')) { return data.factory.opNumber(data.factory.toExprNumber(expr), data.factory.createLitNumber(1), Factory.OP_DBL_PLUS); } comments(data); - return data.factory.toExprNumber(clip(data)); + return data.factory.opNegateNumber(clip(data), Factory.OP_NEG_NBR_PLUS, line, data.srcCode.getPosition()); } return clip(data); } diff --git a/test/tickets/LDEV6018.cfc b/test/tickets/LDEV6018.cfc index e52bf0b6e6b..5e8cf3a55c0 100644 --- a/test/tickets/LDEV6018.cfc +++ b/test/tickets/LDEV6018.cfc @@ -34,6 +34,215 @@ component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { }); + describe( "LDEV-6018: Dynamic struct keys should not be wrapped in CastExpression", function() { + + it( "should preserve MemberExpression in struct key with interpolation", function() { + var code = 'x = { "##arguments.name##": "value" };'; + var ast = astFromString( code, "script" ); + + // Find the struct property key + var prop = ast.body[ 1 ].right.properties[ 1 ]; + expect( prop.type ).toBe( "Property" ); + + // Bug: key is parsed as MemberExpression first time, but after round-trip + // with "#ARGUMENTS.NAME#" output, it becomes CastExpression + // The key should remain a MemberExpression (or at minimum, be consistent) + expect( prop.key.type ).toBe( "MemberExpression", "Struct key should be MemberExpression, not wrapped in CastExpression" ); + }); + + it( "should preserve CallExpression in struct key with interpolation", function() { + var code = 'x = { "##getKey()##": "value" };'; + var ast = astFromString( code, "script" ); + + var prop = ast.body[ 1 ].right.properties[ 1 ]; + expect( prop.type ).toBe( "Property" ); + + // Bug: key is CallExpression first time, but becomes CastExpression after round-trip + expect( prop.key.type ).toBe( "CallExpression", "Struct key should be CallExpression, not wrapped in CastExpression" ); + }); + + }); + + describe( "LDEV-6018: Interpolated expressions should parse consistently", function() { + + it( "should parse simple interpolated function name as Identifier", function() { + // Simple "#funcName#"() parses callee as Identifier directly + var code = '"##funcName##"();'; + var ast = astFromString( code, "script" ); + + var callExpr = ast.body[ 1 ]; + expect( callExpr.type ).toBe( "CallExpression" ); + expect( callExpr.callee.type ).toBe( "Identifier" ); + expect( callExpr.callee.name ).toBe( "FUNCNAME" ); + }); + + it( "should parse complex interpolated new expression as CastExpression", function() { + // new "#arguments.reporter#"() should have CastExpression callee + var code = 'new "##arguments.reporter##"();'; + var ast = astFromString( code, "script" ); + + var newExpr = ast.body[ 1 ]; + expect( newExpr.type ).toBe( "NewExpression" ); + + // The callee is a CastExpression wrapping a MemberExpression + expect( newExpr.callee.type ).toBe( "CastExpression", "Interpolated new callee should be CastExpression" ); + expect( newExpr.callee.argument.type ).toBe( "MemberExpression" ); + }); + + it( "should parse complex interpolated call expression as MemberExpression", function() { + // "#arguments.reporter#"() (not new) should have MemberExpression callee + var code = '"##arguments.reporter##"();'; + var ast = astFromString( code, "script" ); + + var callExpr = ast.body[ 1 ]; + expect( callExpr.type ).toBe( "CallExpression" ); + + // Unlike new expressions, regular calls parse the interpolated expression directly + expect( callExpr.callee.type ).toBe( "MemberExpression" ); + }); + + }); + + describe( "LDEV-6018: Unary plus should be UnaryExpression, not CastExpression", function() { + + it( "should parse unary plus as UnaryExpression like unary minus", function() { + var plusCode = 'x = +num;'; + var minusCode = 'x = -num;'; + + var plusAst = astFromString( plusCode, "script" ); + var minusAst = astFromString( minusCode, "script" ); + + var plusExpr = plusAst.body[1].right; + var minusExpr = minusAst.body[1].right; + + // Unary minus correctly uses UnaryExpression + expect( minusExpr.type ).toBe( "UnaryExpression" ); + expect( minusExpr.operator ).toBe( "NEGATE" ); + + // Bug: Unary plus uses CastExpression with typeAnnotation="number" + // Should be UnaryExpression with operator="PLUS" (or similar) + expect( plusExpr.type ).toBe( "UnaryExpression", + "Unary plus should be UnaryExpression, not #plusExpr.type#" ); + }); + + it( "should preserve unary plus in function arguments", function() { + var code = 'DateAdd( "d", +num, now() );'; + var ast = astFromString( code, "script" ); + + var arg = ast.body[1].arguments[2]; + + // The +num argument should be distinguishable from just num + // Currently it's CastExpression which loses the + sign + expect( arg.type ).toBe( "UnaryExpression", + "Unary plus in argument should be UnaryExpression, not #arg.type#" ); + }); + + }); + + describe( "LDEV-6018: Integer divide should have distinct operator", function() { + + it( "should distinguish integer divide from regular divide", function() { + var intDivCode = 'x = a \ b;'; + var divCode = 'x = a / b;'; + + var intDivAst = astFromString( intDivCode, "script" ); + var divAst = astFromString( divCode, "script" ); + + var intDivExpr = intDivAst.body[1].right; + var divExpr = divAst.body[1].right; + + expect( intDivExpr.type ).toBe( "BinaryExpression" ); + expect( divExpr.type ).toBe( "BinaryExpression" ); + + // Bug: Both have operator "DIVIDE" - should be different + // Integer divide should be "INTDIV" or "INTEGER_DIVIDE" + expect( divExpr.operator ).toBe( "DIVIDE" ); + expect( intDivExpr.operator ).toBe( "INTDIV", + "Integer divide (\\) should have operator 'INTDIV', not '#intDivExpr.operator#'" ); + }); + + }); + + describe( "LDEV-6018: Safe navigation should have optional flag", function() { + + it( "should distinguish safe navigation from regular member access", function() { + var safeCode = 'x = obj?.prop;'; + var normalCode = 'x = obj.prop;'; + + var safeAst = astFromString( safeCode, "script" ); + var normalAst = astFromString( normalCode, "script" ); + + var safeExpr = safeAst.body[1].right; + var normalExpr = normalAst.body[1].right; + + expect( safeExpr.type ).toBe( "MemberExpression" ); + expect( normalExpr.type ).toBe( "MemberExpression" ); + + // Bug: Both look identical - safe navigation should have optional=true + expect( normalExpr.optional ?: false ).toBe( false ); + expect( safeExpr ).toHaveKey( "optional", + "Safe navigation should have 'optional' field" ); + expect( safeExpr.optional ).toBe( true, + "Safe navigation (?.) should have optional=true" ); + }); + + it( "should preserve safe navigation in chained access", function() { + var code = 'x = obj?.nested?.value;'; + var ast = astFromString( code, "script" ); + + var expr = ast.body[1].right; + + // Outer member access (?.value) + expect( expr.type ).toBe( "MemberExpression" ); + expect( expr ).toHaveKey( "optional" ); + expect( expr.optional ).toBe( true ); + + // Inner member access (obj?.nested) + expect( expr.object.type ).toBe( "MemberExpression" ); + expect( expr.object ).toHaveKey( "optional" ); + expect( expr.object.optional ).toBe( true ); + }); + + }); + + describe( "LDEV-6018: Compound assignment operators should be preserved", function() { + + it( "should preserve modulo compound assignment", function() { + var code = 'x %= 3;'; + var ast = astFromString( code, "script" ); + + var expr = ast.body[1]; + expect( expr.type ).toBe( "AssignmentExpression" ); + + // Bug: operator is "ASSIGN" and right is expanded to x % 3 + // Should be operator "MODULUS_ASSIGN" or similar + expect( expr.operator ).toBe( "MODULUS_ASSIGN", + "Compound %%== should have operator 'MODULUS_ASSIGN', not '#expr.operator#'" ); + }); + + it( "should preserve all compound assignment operators", function() { + var ops = { + "x += 1": "PLUS_ASSIGN", + "x -= 1": "MINUS_ASSIGN", + "x *= 2": "MULTIPLY_ASSIGN", + "x /= 2": "DIVIDE_ASSIGN", + "x %= 2": "MODULUS_ASSIGN", + "x &= 'a'": "CONCAT_ASSIGN" + }; + + for ( var code in ops ) { + var expected = ops[ code ]; + var ast = astFromString( code & ";", "script" ); + var expr = ast.body[1]; + + expect( expr.type ).toBe( "AssignmentExpression" ); + expect( expr.operator ).toBe( expected, + "'#code#' should have operator '#expected#', not '#expr.operator#'" ); + } + }); + + }); + } } From 8027b72927f7704ab0a23f8e23c89f31242797f1 Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Sun, 21 Dec 2025 14:55:53 +0100 Subject: [PATCH 52/71] LDEV-6018 fix safe navigation optional flag bleeding to previous member --- .../expression/AbstrCFMLExprTransformer.java | 8 ---- test/tickets/LDEV6018.cfc | 48 +++++++++++++++++++ 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/core/src/main/java/lucee/transformer/cfml/expression/AbstrCFMLExprTransformer.java b/core/src/main/java/lucee/transformer/cfml/expression/AbstrCFMLExprTransformer.java index b8614db5288..d4b045e88e1 100755 --- a/core/src/main/java/lucee/transformer/cfml/expression/AbstrCFMLExprTransformer.java +++ b/core/src/main/java/lucee/transformer/cfml/expression/AbstrCFMLExprTransformer.java @@ -1513,15 +1513,7 @@ else if (isStaticChild || data.srcCode.forwardIfCurrent('.') || (safeNavigation expr = invoker; } - // safe navigation Member member; - if (safeNavigation) { - List members = invoker.getMembers(); - if (members.size() > 0) { - member = members.get(members.size() - 1); - member.setSafeNavigated(true); - } - } // Method if (data.srcCode.isCurrent('(')) { diff --git a/test/tickets/LDEV6018.cfc b/test/tickets/LDEV6018.cfc index 5e8cf3a55c0..6a6b75a2889 100644 --- a/test/tickets/LDEV6018.cfc +++ b/test/tickets/LDEV6018.cfc @@ -203,6 +203,54 @@ component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { expect( expr.object.optional ).toBe( true ); }); + it( "should NOT bleed optional flag to previous member in mixed chain", function() { + // Bug: a.b.c?.d incorrectly sets optional on BOTH .d AND .c + // Only .d should have optional=true + var code = 'x = a.b.c?.d;'; + var ast = astFromString( code, "script" ); + + var expr = ast.body[1].right; + + // Structure: MemberExpression(.d) -> MemberExpression(.c) -> MemberExpression(.b) -> Identifier(a) + + // .d - should have optional=true (we used ?.) + expect( expr.type ).toBe( "MemberExpression" ); + expect( expr.property.name ).toBe( "D" ); + expect( expr ).toHaveKey( "optional" ); + expect( expr.optional ).toBe( true, ".d should have optional=true" ); + + // .c - should NOT have optional (we used regular .) + expect( expr.object.type ).toBe( "MemberExpression" ); + expect( expr.object.property.name ).toBe( "C" ); + expect( expr.object.optional ?: false ).toBe( false, + ".c should NOT have optional=true - the flag is bleeding from ?.d" ); + + // .b - should NOT have optional + expect( expr.object.object.type ).toBe( "MemberExpression" ); + expect( expr.object.object.property.name ).toBe( "B" ); + expect( expr.object.object.optional ?: false ).toBe( false, + ".b should NOT have optional=true" ); + }); + + it( "should correctly mark only the safe-navigated member in middle of chain", function() { + // a.b?.c.d - only .c should have optional + var code = 'x = a.b?.c.d;'; + var ast = astFromString( code, "script" ); + + var expr = ast.body[1].right; + + // .d - should NOT have optional + expect( expr.optional ?: false ).toBe( false, ".d should NOT have optional" ); + + // .c - should have optional=true (we used ?.) + expect( expr.object ).toHaveKey( "optional" ); + expect( expr.object.optional ).toBe( true, ".c should have optional=true" ); + + // .b - should NOT have optional + expect( expr.object.object.optional ?: false ).toBe( false, + ".b should NOT have optional=true - the flag is bleeding from ?.c" ); + }); + }); describe( "LDEV-6018: Compound assignment operators should be preserved", function() { From 7e23764a89319f1f1313e0426c23367ab459f0ca Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Sun, 21 Dec 2025 16:43:54 +0100 Subject: [PATCH 53/71] LDEV-6034 hide auto-generated names from closure/lambda AST output --- .../bytecode/statement/udf/Closure.java | 3 ++ .../bytecode/statement/udf/Lambda.java | 3 ++ test/tickets/LDEV6034.cfc | 42 +++++++++++++++++++ 3 files changed, 48 insertions(+) create mode 100644 test/tickets/LDEV6034.cfc diff --git a/core/src/main/java/lucee/transformer/bytecode/statement/udf/Closure.java b/core/src/main/java/lucee/transformer/bytecode/statement/udf/Closure.java index 941d68d03cb..7ffad7b07a4 100644 --- a/core/src/main/java/lucee/transformer/bytecode/statement/udf/Closure.java +++ b/core/src/main/java/lucee/transformer/bytecode/statement/udf/Closure.java @@ -19,6 +19,7 @@ package lucee.transformer.bytecode.statement.udf; import lucee.runtime.type.Struct; +import lucee.runtime.type.util.KeyConstants; import lucee.transformer.Body; import lucee.transformer.Position; import lucee.transformer.TransformerException; @@ -53,5 +54,7 @@ public int getType() { @Override public void dump(Struct sct) { dump(sct, "ClosureDeclaration"); + // Remove auto-generated name - anonymous closures shouldn't expose internal names + sct.removeEL(KeyConstants._name); } } \ No newline at end of file diff --git a/core/src/main/java/lucee/transformer/bytecode/statement/udf/Lambda.java b/core/src/main/java/lucee/transformer/bytecode/statement/udf/Lambda.java index bc84e5ab235..1f4fb759e04 100644 --- a/core/src/main/java/lucee/transformer/bytecode/statement/udf/Lambda.java +++ b/core/src/main/java/lucee/transformer/bytecode/statement/udf/Lambda.java @@ -18,6 +18,7 @@ package lucee.transformer.bytecode.statement.udf; import lucee.runtime.type.Struct; +import lucee.runtime.type.util.KeyConstants; import lucee.transformer.Body; import lucee.transformer.Position; import lucee.transformer.Root; @@ -53,5 +54,7 @@ public int getType() { @Override public void dump(Struct sct) { dump(sct, "LambdaDeclaration"); + // Remove auto-generated name - lambdas shouldn't expose internal names + sct.removeEL(KeyConstants._name); } } \ No newline at end of file diff --git a/test/tickets/LDEV6034.cfc b/test/tickets/LDEV6034.cfc new file mode 100644 index 00000000000..de0b7ecd68e --- /dev/null +++ b/test/tickets/LDEV6034.cfc @@ -0,0 +1,42 @@ +component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { + + function run( testResults, testBox ) { + + describe( "LDEV-6034: AST should not expose auto-generated closure/lambda names", function() { + + it( "should not include internal name for anonymous closure", function() { + var ast = astFromString( 'arr.each( function( item ) { return item; } );', "script" ); + // body[1] is CallExpression directly (no ExpressionStatement wrapper) + var callExpr = ast.body[1]; + expect( callExpr.type ).toBe( "CallExpression" ); + var closureArg = callExpr.arguments[1]; + expect( closureArg.type ).toBe( "ClosureDeclaration" ); + // Anonymous closure should NOT have a name field - it's internal implementation detail + expect( closureArg ).notToHaveKey( "name", "Anonymous closure should not expose auto-generated internal name" ); + }); + + it( "should not include internal name for lambda expression", function() { + var ast = astFromString( 'x = () => 1;', "script" ); + var assignment = ast.body[1]; + expect( assignment.type ).toBe( "AssignmentExpression" ); + var lambda = assignment.right; + expect( lambda.type ).toBe( "LambdaDeclaration" ); + // Lambda should NOT have a name field - it's internal implementation detail + expect( lambda ).notToHaveKey( "name", "Lambda should not expose auto-generated internal name" ); + }); + + it( "should not include internal name for inline lambda in array method", function() { + var ast = astFromString( '[1,2,3].map( ( x ) => x * 2 );', "script" ); + // body[1] is CallExpression directly + var callExpr = ast.body[1]; + expect( callExpr.type ).toBe( "CallExpression" ); + var lambdaArg = callExpr.arguments[1]; + expect( lambdaArg.type ).toBe( "LambdaDeclaration" ); + // Lambda should NOT have a name field + expect( lambdaArg ).notToHaveKey( "name", "Inline lambda should not expose auto-generated internal name" ); + }); + + }); + } + +} From b7cee551effe9222d4b2357ed1180178e5ff97c3 Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Sun, 21 Dec 2025 18:00:34 +0100 Subject: [PATCH 54/71] LDEV-6018 output UpdateExpression for increment/decrement operators --- .../transformer/bytecode/op/AbsOpUnary.java | 26 ++++++ test/tickets/LDEV6018.cfc | 79 +++++++++++++++++++ 2 files changed, 105 insertions(+) diff --git a/core/src/main/java/lucee/transformer/bytecode/op/AbsOpUnary.java b/core/src/main/java/lucee/transformer/bytecode/op/AbsOpUnary.java index c5f59db7413..564ace22a0f 100644 --- a/core/src/main/java/lucee/transformer/bytecode/op/AbsOpUnary.java +++ b/core/src/main/java/lucee/transformer/bytecode/op/AbsOpUnary.java @@ -228,9 +228,35 @@ private static String toCompoundOperator(int operation) { return "UNKNOWN_ASSIGN"; } + // Check if this is an increment/decrement (++i, i++, --i, i--) + private boolean isUpdateExpression() { + // Must be plus or minus operation + if (operation != Factory.OP_UNARY_PLUS && operation != Factory.OP_UNARY_MINUS) { + return false; + } + // For ++i and i++, the parser uses factory.NUMBER_ONE() singleton + // For x += 1, it parses the value from source creating a different object + // Reference equality distinguishes these cases + return value == var.getFactory().NUMBER_ONE(); + } + @Override public void dump(Struct sct) { super.dump(sct); + + // Check if this is ++i, i++, --i, i-- (UpdateExpression) + if (isUpdateExpression()) { + sct.setEL(KeyConstants._type, "UpdateExpression"); + sct.setEL(KeyConstants._operator, operation == Factory.OP_UNARY_PLUS ? "++" : "--"); + sct.setEL(KeyConstants._prefix, type == Factory.OP_UNARY_PRE); + + // argument - the variable being updated + Struct argument = new StructImpl(Struct.TYPE_LINKED); + sct.setEL(KeyConstants._argument, argument); + var.dump(argument); + return; + } + // Output as AssignmentExpression with compound operator sct.setEL(KeyConstants._type, "AssignmentExpression"); sct.setEL(KeyConstants._operator, toCompoundOperator(operation)); diff --git a/test/tickets/LDEV6018.cfc b/test/tickets/LDEV6018.cfc index 6a6b75a2889..3109792a191 100644 --- a/test/tickets/LDEV6018.cfc +++ b/test/tickets/LDEV6018.cfc @@ -291,6 +291,85 @@ component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { }); + describe( "LDEV-6018: Increment/decrement should be UpdateExpression, not AssignmentExpression", function() { + + it( "should parse pre-increment as UpdateExpression", function() { + var code = 'x = ++i;'; + var ast = astFromString( code, "script" ); + + var expr = ast.body[1].right; + + // Bug: ++i is parsed as AssignmentExpression with PLUS_ASSIGN + // Should be UpdateExpression with prefix=true + expect( expr.type ).toBe( "UpdateExpression", + "Pre-increment ++i should be UpdateExpression, not #expr.type#" ); + expect( expr ).toHaveKey( "prefix" ); + expect( expr.prefix ).toBe( true, "Pre-increment should have prefix=true" ); + expect( expr.operator ).toBe( "++" ); + }); + + it( "should parse post-increment as UpdateExpression", function() { + var code = 'x = i++;'; + var ast = astFromString( code, "script" ); + + var expr = ast.body[1].right; + + // Bug: i++ is parsed as AssignmentExpression with PLUS_ASSIGN + // Should be UpdateExpression with prefix=false + expect( expr.type ).toBe( "UpdateExpression", + "Post-increment i++ should be UpdateExpression, not #expr.type#" ); + expect( expr ).toHaveKey( "prefix" ); + expect( expr.prefix ).toBe( false, "Post-increment should have prefix=false" ); + expect( expr.operator ).toBe( "++" ); + }); + + it( "should parse pre-decrement as UpdateExpression", function() { + var code = 'x = --i;'; + var ast = astFromString( code, "script" ); + + var expr = ast.body[1].right; + + expect( expr.type ).toBe( "UpdateExpression", + "Pre-decrement --i should be UpdateExpression, not #expr.type#" ); + expect( expr ).toHaveKey( "prefix" ); + expect( expr.prefix ).toBe( true ); + expect( expr.operator ).toBe( "--" ); + }); + + it( "should parse post-decrement as UpdateExpression", function() { + var code = 'x = i--;'; + var ast = astFromString( code, "script" ); + + var expr = ast.body[1].right; + + expect( expr.type ).toBe( "UpdateExpression", + "Post-decrement i-- should be UpdateExpression, not #expr.type#" ); + expect( expr ).toHaveKey( "prefix" ); + expect( expr.prefix ).toBe( false ); + expect( expr.operator ).toBe( "--" ); + }); + + it( "should allow increment as inline expression", function() { + // This is the key issue - ++i must be usable as an expression + var code = 'x = int( 100 / count * ++i );'; + var ast = astFromString( code, "script" ); + + var callExpr = ast.body[1].right; + expect( callExpr.type ).toBe( "CallExpression" ); + + // Find the ++i in the multiply expression + var multiplyExpr = callExpr.arguments[1]; + expect( multiplyExpr.type ).toBe( "BinaryExpression" ); + expect( multiplyExpr.operator ).toBe( "MULTIPLY" ); + + var incExpr = multiplyExpr.right; + // Bug: This is AssignmentExpression which can't be used inline + expect( incExpr.type ).toBe( "UpdateExpression", + "Inline ++i should be UpdateExpression for proper round-trip" ); + }); + + }); + } } From dbb4e4b96429fb0c318afd05a1307df821d759a0 Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Sun, 21 Dec 2025 19:09:06 +0100 Subject: [PATCH 55/71] LDEV-6035 include fullname for shorthand script tags --- .../script/AbstrCFMLScriptTransformer.java | 9 +- test/tickets/LDEV6035.cfc | 100 ++++++++++++++++++ 2 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 test/tickets/LDEV6035.cfc diff --git a/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java b/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java index db50432d0b6..06b63700d6c 100755 --- a/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java +++ b/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java @@ -1500,7 +1500,13 @@ private final Tag __multiAttrStatement(Body parent, Data data, TagLibTag tlt) th Tag tag = getTag(data, parent, tlt, line, null); tag.setTagLibTag(tlt); tag.setScriptBase(true); - if (!StringUtil.isEmpty(appendix)) tag.setAppendix(appendix); + if (!StringUtil.isEmpty(appendix)) { + tag.setAppendix(appendix); + tag.setFullname(type.concat(appendix)); + } + else { + tag.setFullname(type); + } // add component meta data if (data.isCFC) { @@ -2110,6 +2116,7 @@ private final Statement __singleAttrStatement(Body parent, Data data, TagLibTag Tag tag = getTag(data, parent, tlt, line, null); tag.setScriptBase(true); tag.setTagLibTag(tlt); + tag.setFullname(tagName); comments(data); diff --git a/test/tickets/LDEV6035.cfc b/test/tickets/LDEV6035.cfc new file mode 100644 index 00000000000..6c5342037f5 --- /dev/null +++ b/test/tickets/LDEV6035.cfc @@ -0,0 +1,100 @@ +component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { + + function run( testResults, testBox ) { + + describe( "LDEV-6035: AST fullname field should be consistent for shorthand vs cf-prefixed tags", function() { + + it( "should include fullname for cf-prefixed script tag", function() { + var code = 'cfabort;'; + var ast = astFromString( code, "script" ); + + var tag = ast.body[1]; + expect( tag.type ).toBe( "CFMLTag" ); + expect( tag.name ).toBe( "ABORT" ); + expect( tag ).toHaveKey( "fullname", "cf-prefixed tag should have fullname" ); + expect( tag.fullname ).toBe( "cfabort" ); + }); + + it( "should include fullname for shorthand script tag", function() { + var code = 'abort;'; + var ast = astFromString( code, "script" ); + + var tag = ast.body[1]; + expect( tag.type ).toBe( "CFMLTag" ); + expect( tag.name ).toBe( "ABORT" ); + // Bug: shorthand tag lacks fullname field + expect( tag ).toHaveKey( "fullname", + "Shorthand tag should have fullname for consistency" ); + expect( tag.fullname ).toBe( "abort", + "Shorthand fullname should be 'abort', not 'cfabort'" ); + }); + + it( "should include fullname for cf-prefixed tag with attributes", function() { + var code = 'cfdump( var=x );'; + var ast = astFromString( code, "script" ); + + var tag = ast.body[1]; + expect( tag.type ).toBe( "CFMLTag" ); + expect( tag.name ).toBe( "DUMP" ); + expect( tag ).toHaveKey( "fullname" ); + expect( tag.fullname ).toBe( "cfdump" ); + }); + + it( "should include fullname for shorthand tag with named param syntax", function() { + // Note: dump( var=x ) parses as CallExpression, not CFMLTag + // Use cfhttp which has no function equivalent + var code = 'http url="http://example.com" {}'; + var ast = astFromString( code, "script" ); + + var tag = ast.body[1]; + expect( tag.type ).toBe( "CFMLTag" ); + expect( tag.name ).toBe( "HTTP" ); + // Bug: shorthand tag lacks fullname field + expect( tag ).toHaveKey( "fullname", + "Shorthand tag should have fullname" ); + expect( tag.fullname ).toBe( "http" ); + }); + + it( "should include fullname for block tags", function() { + var codeShorthand = 'loop from=1 to=10 index="i" {}'; + var codePrefixed = 'cfloop( from=1, to=10, index="i" ) {}'; + + var astShorthand = astFromString( codeShorthand, "script" ); + var astPrefixed = astFromString( codePrefixed, "script" ); + + var tagShorthand = astShorthand.body[1]; + var tagPrefixed = astPrefixed.body[1]; + + expect( tagPrefixed ).toHaveKey( "fullname" ); + expect( tagPrefixed.fullname ).toBe( "cfloop" ); + + // Bug: shorthand lacks fullname + expect( tagShorthand ).toHaveKey( "fullname", + "Shorthand block tag should have fullname" ); + expect( tagShorthand.fullname ).toBe( "loop" ); + }); + + it( "should distinguish shorthand from cf-prefixed in round-trip", function() { + // The key issue: after round-trip, we lose info about original syntax + var shorthandCode = 'abort;'; + var prefixedCode = 'cfabort;'; + + var shorthandAst = astFromString( shorthandCode, "script" ); + var prefixedAst = astFromString( prefixedCode, "script" ); + + // Both have same name + expect( shorthandAst.body[1].name ).toBe( prefixedAst.body[1].name ); + + // But fullname should differ to preserve original syntax + if ( shorthandAst.body[1].keyExists( "fullname" ) && + prefixedAst.body[1].keyExists( "fullname" ) ) { + expect( shorthandAst.body[1].fullname ).notToBe( prefixedAst.body[1].fullname, + "fullname should differ between shorthand and cf-prefixed" ); + } + }); + + }); + + } + +} From e2c75a2c6928f5f0fc5d4456dc032846b3484ee4 Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Sun, 21 Dec 2025 22:08:47 +0100 Subject: [PATCH 56/71] LDEV-5990 preserve raw docblock and metadata in AST --- .../bytecode/statement/tag/TagBase.java | 11 + .../bytecode/statement/udf/Function.java | 29 ++ .../expression/AbstrCFMLExprTransformer.java | 3 +- .../script/AbstrCFMLScriptTransformer.java | 6 + .../transformer/cfml/script/DocComment.java | 9 + .../cfml/script/DocCommentTransformer.java | 1 + test/tickets/LDEV5990.cfc | 300 +++++++++++++++++- test/tickets/LDEV5990/docblockVariations.cfc | 42 +++ test/tickets/LDEV5990/tagBasedParams.cfc | 26 ++ 9 files changed, 425 insertions(+), 2 deletions(-) create mode 100644 test/tickets/LDEV5990/docblockVariations.cfc create mode 100644 test/tickets/LDEV5990/tagBasedParams.cfc diff --git a/core/src/main/java/lucee/transformer/bytecode/statement/tag/TagBase.java b/core/src/main/java/lucee/transformer/bytecode/statement/tag/TagBase.java index fd7bd5ffcb9..17a51b98a1e 100755 --- a/core/src/main/java/lucee/transformer/bytecode/statement/tag/TagBase.java +++ b/core/src/main/java/lucee/transformer/bytecode/statement/tag/TagBase.java @@ -56,6 +56,7 @@ public abstract class TagBase extends StatementBase implements Tag { private Map metadata; private Map sourceAttributes; // original attributes before removeAttribute() calls + private String rawDocblock; // raw docblock text for AST round-tripping (components/interfaces) // private Label finallyLabel; public TagBase(Factory factory, Position start, Position end) { @@ -202,6 +203,14 @@ public Map getMetaData() { return metadata; } + public void setRawDocblock(String rawDocblock) { + this.rawDocblock = rawDocblock; + } + + public String getRawDocblock() { + return rawDocblock; + } + @Override public void dump(Struct sct) { super.dump(sct); @@ -226,6 +235,8 @@ public void dump(Struct sct) { sct.setEL(KeyConstants._nameSpaceSeparator, tagLibTag.getTagLib().getNameSpaceSeparator()); if (appendix != null) sct.setEL(KeyConstants._appendix, appendix); if (fullname != null) sct.setEL(KeyConstants._fullname, fullname); + // docblock - raw docblock text for round-tripping (component/interface) + if (rawDocblock != null) sct.setEL("docblock", rawDocblock); // attributes (use sourceAttributes if available, as removeAttribute() may have removed some) Array arrAttrs = new ArrayImpl(); diff --git a/core/src/main/java/lucee/transformer/bytecode/statement/udf/Function.java b/core/src/main/java/lucee/transformer/bytecode/statement/udf/Function.java index d3748665e21..32e6bd9e54a 100644 --- a/core/src/main/java/lucee/transformer/bytecode/statement/udf/Function.java +++ b/core/src/main/java/lucee/transformer/bytecode/statement/udf/Function.java @@ -142,6 +142,7 @@ public abstract class Function extends StatementBaseNoFinal implements Opcodes, ExprString displayName; ExprString hint; String hintSource; // "docblock" or "attribute" - indicates where hint came from + String rawDocblock; // raw docblock text for AST round-tripping Body body; List arguments = new ArrayList(); Map metadata; @@ -539,6 +540,10 @@ public final void setHint(Factory factory, String hint) { this.hintSource = "docblock"; } + public final void setRawDocblock(String rawDocblock) { + this.rawDocblock = rawDocblock; + } + public final void addAttribute(BytecodeContext bc, Attribute attr) throws TemplateException { String name = attr.getName().toLowerCase(); // name @@ -719,6 +724,30 @@ public void dump(Struct sct, String type) { if (hintSource != null) { sct.setEL("hintSource", hintSource); } + // docblock - raw docblock text for round-tripping (only when hintSource is docblock) + if (rawDocblock != null && "docblock".equals(hintSource)) { + sct.setEL("docblock", rawDocblock); + } + // metadata - @return, @deprecated, and other custom tags from docblock + if (metadata != null && !metadata.isEmpty()) { + Struct meta = new StructImpl(Struct.TYPE_LINKED); + for (Map.Entry entry: metadata.entrySet()) { + String key = entry.getKey(); + Attribute attr = entry.getValue(); + Expression val = attr.getValue(); + if (val instanceof Literal) { + // For simple values, output the string directly + meta.setEL(key, ((Literal) val).getString()); + } + else { + // For complex values, dump the expression + Struct s = new StructImpl(Struct.TYPE_LINKED); + val.dump(s); + meta.setEL(key, s); + } + } + sct.setEL(KeyConstants._metadata, meta); + } // secureJson if (secureJson != null) { Struct s = new StructImpl(Struct.TYPE_LINKED); diff --git a/core/src/main/java/lucee/transformer/cfml/expression/AbstrCFMLExprTransformer.java b/core/src/main/java/lucee/transformer/cfml/expression/AbstrCFMLExprTransformer.java index d4b045e88e1..24baa3b88a7 100755 --- a/core/src/main/java/lucee/transformer/cfml/expression/AbstrCFMLExprTransformer.java +++ b/core/src/main/java/lucee/transformer/cfml/expression/AbstrCFMLExprTransformer.java @@ -2125,7 +2125,8 @@ private boolean multiLineComment(Data data) throws TemplateException { throw new TemplateException(cfml, "block comment is not closed"); } if (isDocComment && !data.insideFunction) { - String comment = cfml.substring(pos - 2, cfml.getPos() - pos); + // Include the full comment from /** to */ inclusive + String comment = cfml.substring(pos - 2, cfml.getPos() - (pos - 2)); data.docComment = docCommentTransformer.transform(data.factory, comment); } return true; diff --git a/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java b/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java index 06b63700d6c..4fef6e69399 100755 --- a/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java +++ b/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java @@ -1096,6 +1096,7 @@ protected final Function closurePart(Data data, String id, int access, int modif if (data.docComment != null) { func.setHint(data.factory, hint = data.docComment.getHint()); func.setMetaData(data.docComment.getParams()); + func.setRawDocblock(data.docComment.getRawText()); data.docComment = null; } @@ -1673,6 +1674,11 @@ private final void addMetaData(Data data, Tag tag, String[] ignoreList) { tag.addMetaData(data.docComment.getHintAsAttribute(data.factory)); + // Store raw docblock for AST round-tripping + if (tag instanceof TagBase) { + ((TagBase) tag).setRawDocblock(data.docComment.getRawText()); + } + Map params = data.docComment.getParams(); Iterator it = params.values().iterator(); Attribute attr; diff --git a/core/src/main/java/lucee/transformer/cfml/script/DocComment.java b/core/src/main/java/lucee/transformer/cfml/script/DocComment.java index 58a571cfa68..7370f7ed8c7 100644 --- a/core/src/main/java/lucee/transformer/cfml/script/DocComment.java +++ b/core/src/main/java/lucee/transformer/cfml/script/DocComment.java @@ -30,9 +30,18 @@ public final class DocComment { private StringBuilder tmpHint = new StringBuilder(); private String hint; + private String rawText; // raw docblock text for AST round-tripping // private List params=new ArrayList(); Map params = new HashMap(); + public void setRawText(String raw) { + this.rawText = raw; + } + + public String getRawText() { + return rawText; + } + public void addHint(char c) { tmpHint.append(c); } diff --git a/core/src/main/java/lucee/transformer/cfml/script/DocCommentTransformer.java b/core/src/main/java/lucee/transformer/cfml/script/DocCommentTransformer.java index 970356bf891..42e67aeb29c 100644 --- a/core/src/main/java/lucee/transformer/cfml/script/DocCommentTransformer.java +++ b/core/src/main/java/lucee/transformer/cfml/script/DocCommentTransformer.java @@ -31,6 +31,7 @@ public final class DocCommentTransformer { public DocComment transform(Factory f, String str) { try { DocComment dc = new DocComment(); + dc.setRawText(str); // preserve raw docblock for AST round-tripping str = str.trim(); if (str.startsWith("/**")) str = str.substring(3); if (str.endsWith("*/")) str = str.substring(0, str.length() - 2); diff --git a/test/tickets/LDEV5990.cfc b/test/tickets/LDEV5990.cfc index 6e3723ff251..edc89dfa753 100644 --- a/test/tickets/LDEV5990.cfc +++ b/test/tickets/LDEV5990.cfc @@ -55,6 +55,22 @@ component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { }); + describe( "LDEV-5990: Function attributes like returnFormat should be preserved", function() { + + it( "should include returnFormat attribute on remote functions", function() { + var ast = astFromPath( variables.testDir & "hintedParams.cfc" ); + + // Find the remoteFunc function + var func = findFunction( ast, "remoteFunc" ); + expect( func ).notToBeNull( "remoteFunc function should be found in AST" ); + + // Bug: returnFormat attribute is missing from AST + expect( func ).toHaveKey( "returnFormat", "remote function should have returnFormat in AST" ); + expect( func.returnFormat.value ).toBe( "json", "returnFormat value should be preserved" ); + }); + + }); + describe( "LDEV-5990: Docblock hints should include source annotation", function() { it( "should indicate hint came from docblock vs attribute", function() { @@ -81,15 +97,246 @@ component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { expect( func.hintSource ).toBe( "attribute" ); }); + it( "docblock hints should NOT have quoteChar (original source wasn't quoted)", function() { + var ast = astFromPath( variables.testDir & "hintedParams.cfc" ); + + // Find the withDocblock function (has hint from docblock) + var func = findFunction( ast, "withDocblock" ); + expect( func ).notToBeNull( "withDocblock function should be found in AST" ); + expect( func ).toHaveKey( "hint" ); + expect( func.hintSource ).toBe( "docblock" ); + // Docblock hints should NOT have quoteChar - the original source wasn't quoted + expect( func.hint ).notToHaveKey( "quoteChar", "docblock hints should not have quoteChar" ); + }); + + it( "attribute hints SHOULD have quoteChar (original source was quoted)", function() { + var ast = astFromPath( variables.testDir & "hintedParams.cfc" ); + + // Find the withHintAttr function (has hint="..." attribute) + var func = findFunction( ast, "withHintAttr" ); + expect( func ).notToBeNull( "withHintAttr function should be found in AST" ); + expect( func ).toHaveKey( "hint" ); + expect( func.hintSource ).toBe( "attribute" ); + // Attribute hints SHOULD have quoteChar - the original source was quoted + expect( func.hint ).toHaveKey( "quoteChar", "attribute hints should have quoteChar" ); + }); + + }); + + describe( "LDEV-5990: Raw docblock should be preserved for round-tripping", function() { + + it( "should include raw docblock text in AST when hintSource is docblock", function() { + var ast = astFromPath( variables.testDir & "hintedParams.cfc" ); + + var func = findFunction( ast, "withDocblock" ); + expect( func ).notToBeNull( "withDocblock function should be found in AST" ); + expect( func.hintSource ).toBe( "docblock" ); + // Raw docblock should be preserved for round-trip fidelity + expect( func ).toHaveKey( "docblock", "AST should include raw docblock text" ); + expect( func.docblock ).toInclude( "This hint comes from a docblock" ); + // Must include opening and closing comment markers for valid output + expect( func.docblock ).toInclude( "/**", "docblock should include opening /**" ); + expect( func.docblock ).toInclude( "*/", "docblock should include closing */" ); + }); + + it( "should NOT include docblock key when hint comes from attribute", function() { + var ast = astFromPath( variables.testDir & "hintedParams.cfc" ); + + var func = findFunction( ast, "withHintAttr" ); + expect( func ).notToBeNull( "withHintAttr function should be found in AST" ); + expect( func.hintSource ).toBe( "attribute" ); + // No docblock when hint comes from attribute + expect( func ).notToHaveKey( "docblock", "AST should not have docblock when hint is from attribute" ); + }); + + }); + + describe( "LDEV-5990: Docblock format variations", function() { + + it( "should handle single-line docblock", function() { + var ast = astFromPath( variables.testDir & "docblockVariations.cfc" ); + + var func = findFunction( ast, "singleLineDoc" ); + expect( func ).notToBeNull( "singleLineDoc function should be found in AST" ); + expect( func ).toHaveKey( "docblock" ); + expect( func.docblock ).toInclude( "Single line docblock" ); + expect( func.docblock ).toInclude( "/**" ); + expect( func.docblock ).toInclude( "*/" ); + }); + + it( "should handle docblock with only @tags no description", function() { + var ast = astFromPath( variables.testDir & "docblockVariations.cfc" ); + + var func = findFunction( ast, "onlyTags" ); + expect( func ).notToBeNull( "onlyTags function should be found in AST" ); + expect( func ).toHaveKey( "metadata" ); + expect( func.metadata ).toHaveKey( "return" ); + }); + + it( "should handle docblock with multiple @param tags", function() { + var ast = astFromPath( variables.testDir & "docblockVariations.cfc" ); + + var func = findFunction( ast, "multipleParams" ); + expect( func ).notToBeNull( "multipleParams function should be found in AST" ); + // Each param should have its hint from the docblock + expect( func.params[ 1 ].hint.value ).toBe( "First parameter" ); + expect( func.params[ 2 ].hint.value ).toBe( "Second parameter" ); + expect( func.params[ 3 ].hint.value ).toBe( "Third parameter" ); + }); + + it( "should handle empty docblock (just /** */)", function() { + var ast = astFromPath( variables.testDir & "docblockVariations.cfc" ); + + var func = findFunction( ast, "emptyDocblock" ); + expect( func ).notToBeNull( "emptyDocblock function should be found in AST" ); + // Empty docblock should still be preserved + expect( func ).toHaveKey( "docblock" ); + expect( func.docblock ).toInclude( "/**" ); + expect( func.docblock ).toInclude( "*/" ); + }); + + it( "should handle docblock with special characters", function() { + var ast = astFromPath( variables.testDir & "docblockVariations.cfc" ); + + var func = findFunction( ast, "specialChars" ); + expect( func ).notToBeNull( "specialChars function should be found in AST" ); + expect( func ).toHaveKey( "hint" ); + // Hint should preserve special characters + expect( func.hint.value ).toInclude( "" ); + expect( func.hint.value ).toInclude( "&" ); + expect( func.hint.value ).toInclude( """" ); + }); + + it( "should handle component-level docblock", function() { + var ast = astFromPath( variables.testDir & "docblockVariations.cfc" ); + + // Component tag should have its docblock (not Program level) + var componentTag = ast.body[ 1 ]; + // Script-based components use CFMLTag type + expect( componentTag.type ).toBe( "CFMLTag" ); + expect( componentTag.name ).toBe( "component" ); + expect( componentTag ).toHaveKey( "docblock" ); + expect( componentTag.docblock ).toInclude( "Component-level docblock" ); + expect( componentTag.docblock ).toInclude( "@author" ); + }); + + }); + + describe( "LDEV-5990: Docblock metadata tags should be in AST", function() { + + it( "should include @return tag in AST metadata", function() { + var ast = astFromPath( variables.testDir & "hintedParams.cfc" ); + + var func = findFunction( ast, "withFullDocblock" ); + expect( func ).notToBeNull( "withFullDocblock function should be found in AST" ); + // @return should be accessible in AST + expect( func ).toHaveKey( "metadata", "AST should include metadata from docblock" ); + expect( func.metadata ).toHaveKey( "return", "metadata should include @return" ); + expect( func.metadata.return ).toInclude( "A greeting message" ); + }); + + it( "should include @deprecated tag in AST metadata", function() { + var ast = astFromPath( variables.testDir & "hintedParams.cfc" ); + + var func = findFunction( ast, "withFullDocblock" ); + expect( func ).notToBeNull( "withFullDocblock function should be found in AST" ); + // @deprecated should be accessible in AST + expect( func ).toHaveKey( "metadata", "AST should include metadata from docblock" ); + expect( func.metadata ).toHaveKey( "deprecated", "metadata should include @deprecated" ); + expect( func.metadata.deprecated ).toInclude( "Use greetV2 instead" ); + }); + + it( "should include param hints from docblock @param tags", function() { + var ast = astFromPath( variables.testDir & "hintedParams.cfc" ); + + var func = findFunction( ast, "withFullDocblock" ); + expect( func ).notToBeNull( "withFullDocblock function should be found in AST" ); + // Param hints from @name and @age should be on the params + expect( func.params[ 1 ].hint.value ).toBe( "The name parameter description" ); + expect( func.params[ 2 ].hint.value ).toBe( "The age parameter description" ); + }); + + }); + + describe( "LDEV-5990: Tag-based CFC support", function() { + + it( "should include hint on cfargument in tag-based CFC", function() { + var ast = astFromPath( variables.testDir & "tagBasedParams.cfc" ); + + var func = findFunction( ast, "withHint" ); + expect( func ).notToBeNull( "withHint function should be found in AST" ); + + // Tag-based: cfargument is in body, hint is an attribute + var arg = findTagArgument( func, "name" ); + expect( arg ).notToBeNull( "cfargument 'name' should be found" ); + var hintAttr = getTagAttr( arg, "hint" ); + expect( hintAttr ).notToBeNull( "cfargument should have hint attribute" ); + expect( hintAttr.value.value ).toBe( "The user's full name" ); + }); + + it( "should include default on cfargument in tag-based CFC", function() { + var ast = astFromPath( variables.testDir & "tagBasedParams.cfc" ); + + var func = findFunction( ast, "withDefault" ); + expect( func ).notToBeNull( "withDefault function should be found in AST" ); + + // Tag-based: default is an attribute on cfargument + var arg = findTagArgument( func, "greeting" ); + expect( arg ).notToBeNull( "cfargument 'greeting' should be found" ); + var defaultAttr = getTagAttr( arg, "default" ); + expect( defaultAttr ).notToBeNull( "cfargument should have default attribute" ); + expect( defaultAttr.value.value ).toBe( "Hello" ); + }); + + it( "should include hint on cffunction in tag-based CFC", function() { + var ast = astFromPath( variables.testDir & "tagBasedParams.cfc" ); + + var func = findFunction( ast, "withFuncHint" ); + expect( func ).notToBeNull( "withFuncHint function should be found in AST" ); + + // Tag-based: hint is an attribute on cffunction + var hintAttr = getTagAttr( func, "hint" ); + expect( hintAttr ).notToBeNull( "cffunction should have hint attribute" ); + expect( hintAttr.value.value ).toBe( "Function hint from attribute" ); + }); + + it( "should include returnFormat on remote cffunction", function() { + var ast = astFromPath( variables.testDir & "tagBasedParams.cfc" ); + + var func = findFunction( ast, "remoteFunc" ); + expect( func ).notToBeNull( "remoteFunc function should be found in AST" ); + + // Tag-based: returnformat is an attribute + var rfAttr = getTagAttr( func, "returnformat" ); + expect( rfAttr ).notToBeNull( "cffunction should have returnformat attribute" ); + expect( rfAttr.value.value ).toBe( "json" ); + }); + + it( "should include component-level hint from cfcomponent tag", function() { + var ast = astFromPath( variables.testDir & "tagBasedParams.cfc" ); + + // Find the cfcomponent tag in the body + var comp = ast.body[ 1 ]; + expect( comp.type ).toBe( "CFMLTag" ); + expect( comp.name ).toBe( "component" ); + + // Component hint is an attribute on cfcomponent + var hintAttr = getTagAttr( comp, "hint" ); + expect( hintAttr ).notToBeNull( "cfcomponent should have hint attribute" ); + expect( hintAttr.value.value ).toBe( "Tag-based component hint" ); + }); + }); } /** - * Recursively find a FunctionDeclaration by name + * Recursively find a FunctionDeclaration or CFMLTag function by name */ private function findFunction( required struct node, required string name ) { var nodeType = node.type ?: ""; + + // Script-based: FunctionDeclaration if ( isSimpleValue( nodeType ) && nodeType == "FunctionDeclaration" ) { var nodeName = node.name ?: ""; if ( isStruct( nodeName ) ) nodeName = nodeName.value ?: ""; @@ -97,6 +344,15 @@ component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { return node; } } + + // Tag-based: CFMLTag with name="function" + if ( isSimpleValue( nodeType ) && nodeType == "CFMLTag" && ( node.name ?: "" ) == "function" ) { + var funcName = getTagAttrValue( node, "name" ); + if ( uCase( funcName ) == uCase( name ) ) { + return node; + } + } + for ( var key in node ) { var val = node[ key ]; if ( isStruct( val ) ) { @@ -114,4 +370,46 @@ component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { return; } + /** + * Get attribute value from a CFMLTag's attributes array + */ + private string function getTagAttrValue( required struct tag, required string attrName ) { + var attrs = tag.attributes ?: []; + for ( var attr in attrs ) { + if ( ( attr.name ?: "" ) == attrName ) { + return attr.value.value ?: ""; + } + } + return ""; + } + + /** + * Get attribute struct from a CFMLTag's attributes array + */ + private function getTagAttr( required struct tag, required string attrName ) { + var attrs = tag.attributes ?: []; + for ( var attr in attrs ) { + if ( ( attr.name ?: "" ) == attrName ) { + return attr; + } + } + return; + } + + /** + * Find cfargument tag by name within a cffunction + */ + private function findTagArgument( required struct funcTag, required string argName ) { + var body = funcTag.body.body ?: []; + for ( var item in body ) { + if ( ( item.type ?: "" ) == "CFMLTag" && ( item.name ?: "" ) == "argument" ) { + var name = getTagAttrValue( item, "name" ); + if ( uCase( name ) == uCase( argName ) ) { + return item; + } + } + } + return; + } + } diff --git a/test/tickets/LDEV5990/docblockVariations.cfc b/test/tickets/LDEV5990/docblockVariations.cfc new file mode 100644 index 00000000000..786bb91c5ed --- /dev/null +++ b/test/tickets/LDEV5990/docblockVariations.cfc @@ -0,0 +1,42 @@ +/** + * Component-level docblock + * @author Test Author + * @version 1.0 + */ +component { + + /** Single line docblock */ + function singleLineDoc() { + return "single"; + } + + /** + * @return string The result + */ + function onlyTags() { + return "tags only"; + } + + /** + * Function with multiple params + * @a First parameter + * @b Second parameter + * @c Third parameter + */ + function multipleParams( string a, string b, string c ) { + return a & b & c; + } + + /** */ + function emptyDocblock() { + return "empty"; + } + + /** + * Contains tags & ampersands and "quotes" too + */ + function specialChars() { + return "special"; + } + +} diff --git a/test/tickets/LDEV5990/tagBasedParams.cfc b/test/tickets/LDEV5990/tagBasedParams.cfc new file mode 100644 index 00000000000..1ce0cc3bc1a --- /dev/null +++ b/test/tickets/LDEV5990/tagBasedParams.cfc @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + From 9f91f7b1bdcf73b5ed591d1258c754121bf21729 Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Mon, 22 Dec 2025 00:32:43 +0100 Subject: [PATCH 57/71] =?UTF-8?q?LDEV-5990=20clean=20AST=20separation:=20d?= =?UTF-8?q?ocblock=E2=86=92annotations,=20inline=20attrs=E2=86=92metadata?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Docblock description goes to annotations.description - Docblock @tags go to annotations (e.g. @return, @deprecated) - Inline hint attribute goes to metadata.hint (detected via quoteChar) - Raw docblock preserved in docblock field for round-tripping --- .../bytecode/statement/tag/TagBase.java | 41 ++++ .../bytecode/statement/udf/Function.java | 93 +++++--- .../script/AbstrCFMLScriptTransformer.java | 50 ++-- test/tickets/LDEV5990.cfc | 219 +++++++++++++----- test/tickets/LDEV5990/hintedParams.cfc | 22 ++ 5 files changed, 317 insertions(+), 108 deletions(-) diff --git a/core/src/main/java/lucee/transformer/bytecode/statement/tag/TagBase.java b/core/src/main/java/lucee/transformer/bytecode/statement/tag/TagBase.java index 17a51b98a1e..71ac6d82d6a 100755 --- a/core/src/main/java/lucee/transformer/bytecode/statement/tag/TagBase.java +++ b/core/src/main/java/lucee/transformer/bytecode/statement/tag/TagBase.java @@ -40,6 +40,8 @@ import lucee.transformer.bytecode.visitor.ParseBodyVisitor; import lucee.transformer.library.tag.TagLibTag; import lucee.transformer.library.tag.TagLibTagAttr; +import lucee.transformer.expression.Expression; +import lucee.transformer.expression.literal.Literal; import lucee.transformer.statement.tag.Attribute; import lucee.transformer.statement.tag.Tag; @@ -57,6 +59,8 @@ public abstract class TagBase extends StatementBase implements Tag { private Map metadata; private Map sourceAttributes; // original attributes before removeAttribute() calls private String rawDocblock; // raw docblock text for AST round-tripping (components/interfaces) + private String docblockDescription; // parsed description from docblock for annotations.description + private Map annotations; // @tags from docblock (components/interfaces) // private Label finallyLabel; public TagBase(Factory factory, Position start, Position end) { @@ -211,6 +215,18 @@ public String getRawDocblock() { return rawDocblock; } + public void setDocblockDescription(String description) { + this.docblockDescription = description; + } + + public void setAnnotations(Map annotations) { + this.annotations = annotations; + } + + public Map getAnnotations() { + return annotations; + } + @Override public void dump(Struct sct) { super.dump(sct); @@ -237,6 +253,31 @@ public void dump(Struct sct) { if (fullname != null) sct.setEL(KeyConstants._fullname, fullname); // docblock - raw docblock text for round-tripping (component/interface) if (rawDocblock != null) sct.setEL("docblock", rawDocblock); + // annotations - docblock description + @tags from docblock + if (docblockDescription != null || (annotations != null && !annotations.isEmpty())) { + Struct annot = new StructImpl(Struct.TYPE_LINKED); + // description from docblock first line(s) + if (docblockDescription != null && !docblockDescription.isEmpty()) { + annot.setEL(KeyConstants._description, docblockDescription); + } + // @tags from docblock + if (annotations != null) { + for (Entry entry: annotations.entrySet()) { + String key = entry.getKey(); + Attribute attr = entry.getValue(); + Expression val = attr.getValue(); + if (val instanceof Literal) { + annot.setEL(key, ((Literal) val).getString()); + } + else { + Struct s = new StructImpl(Struct.TYPE_LINKED); + val.dump(s); + annot.setEL(key, s); + } + } + } + sct.setEL("annotations", annot); + } // attributes (use sourceAttributes if available, as removeAttribute() may have removed some) Array arrAttrs = new ArrayImpl(); diff --git a/core/src/main/java/lucee/transformer/bytecode/statement/udf/Function.java b/core/src/main/java/lucee/transformer/bytecode/statement/udf/Function.java index 32e6bd9e54a..69227f375a8 100644 --- a/core/src/main/java/lucee/transformer/bytecode/statement/udf/Function.java +++ b/core/src/main/java/lucee/transformer/bytecode/statement/udf/Function.java @@ -67,6 +67,7 @@ import lucee.transformer.expression.literal.LitInteger; import lucee.transformer.expression.literal.LitString; import lucee.transformer.expression.literal.Literal; +import lucee.transformer.bytecode.literal.LitStringImpl; import lucee.transformer.statement.Argument; import lucee.transformer.statement.HasBody; import lucee.transformer.statement.IFunction; @@ -141,11 +142,12 @@ public abstract class Function extends StatementBaseNoFinal implements Opcodes, int access = Component.ACCESS_PUBLIC; ExprString displayName; ExprString hint; - String hintSource; // "docblock" or "attribute" - indicates where hint came from String rawDocblock; // raw docblock text for AST round-tripping + String docblockDescription; // parsed description from docblock for annotations.description Body body; List arguments = new ArrayList(); - Map metadata; + Map metadata; // inline attributes (not from docblock) + Map annotations; // @tags from docblock ExprString returnFormat; ExprString description; ExprBoolean secureJson; @@ -535,15 +537,22 @@ public final void setMetaData(Map metadata) { this.metadata = metadata; } + public final void setAnnotations(Map annotations) { + this.annotations = annotations; + } + public final void setHint(Factory factory, String hint) { this.hint = factory.createLitString(hint); - this.hintSource = "docblock"; } public final void setRawDocblock(String rawDocblock) { this.rawDocblock = rawDocblock; } + public final void setDocblockDescription(String description) { + this.docblockDescription = description; + } + public final void addAttribute(BytecodeContext bc, Attribute attr) throws TemplateException { String name = attr.getName().toLowerCase(); // name @@ -568,7 +577,6 @@ else if ("access".equals(name)) { else if ("displayname".equals(name)) this.displayName = toLitString(bc, name, attr.getValue()); else if ("hint".equals(name)) { this.hint = toLitString(bc, name, attr.getValue()); - this.hintSource = "attribute"; } else if ("description".equals(name)) this.description = toLitString(bc, name, attr.getValue()); else if ("returnformat".equals(name)) this.returnFormat = toLitString(bc, name, attr.getValue()); @@ -714,36 +722,61 @@ public void dump(Struct sct, String type) { description.dump(s); sct.setEL(KeyConstants._description, s); } - // hint - if (hint != null) { - Struct s = new StructImpl(Struct.TYPE_LINKED); - hint.dump(s); - sct.setEL(KeyConstants._hint, s); + // docblock - raw docblock text for round-tripping + if (rawDocblock != null) { + sct.setEL("docblock", rawDocblock); } - // hintSource - indicates where hint came from (docblock or attribute) - if (hintSource != null) { - sct.setEL("hintSource", hintSource); + // annotations - docblock description + @tags from docblock (@return, @deprecated, etc.) + if (docblockDescription != null || (annotations != null && !annotations.isEmpty())) { + Struct annot = new StructImpl(Struct.TYPE_LINKED); + // description from docblock first line(s) + if (docblockDescription != null && !docblockDescription.isEmpty()) { + annot.setEL(KeyConstants._description, docblockDescription); + } + // @tags from docblock + if (annotations != null) { + for (Map.Entry entry: annotations.entrySet()) { + String key = entry.getKey(); + Attribute attr = entry.getValue(); + Expression val = attr.getValue(); + if (val instanceof Literal) { + annot.setEL(key, ((Literal) val).getString()); + } + else { + Struct s = new StructImpl(Struct.TYPE_LINKED); + val.dump(s); + annot.setEL(key, s); + } + } + } + sct.setEL("annotations", annot); } - // docblock - raw docblock text for round-tripping (only when hintSource is docblock) - if (rawDocblock != null && "docblock".equals(hintSource)) { - sct.setEL("docblock", rawDocblock); + // metadata - inline custom attributes (not from docblock) + // Check if hint is from inline attribute (has quoteChar) vs docblock + boolean hintFromInlineAttr = false; + if (hint instanceof LitStringImpl) { + LitStringImpl ls = (LitStringImpl) hint; + hintFromInlineAttr = ls.getQuoteChar() != (char) 0; } - // metadata - @return, @deprecated, and other custom tags from docblock - if (metadata != null && !metadata.isEmpty()) { + if ((metadata != null && !metadata.isEmpty()) || hintFromInlineAttr) { Struct meta = new StructImpl(Struct.TYPE_LINKED); - for (Map.Entry entry: metadata.entrySet()) { - String key = entry.getKey(); - Attribute attr = entry.getValue(); - Expression val = attr.getValue(); - if (val instanceof Literal) { - // For simple values, output the string directly - meta.setEL(key, ((Literal) val).getString()); - } - else { - // For complex values, dump the expression - Struct s = new StructImpl(Struct.TYPE_LINKED); - val.dump(s); - meta.setEL(key, s); + // Add inline hint to metadata if from attribute + if (hintFromInlineAttr) { + meta.setEL(KeyConstants._hint, ((LitString) hint).getString()); + } + if (metadata != null) { + for (Map.Entry entry: metadata.entrySet()) { + String key = entry.getKey(); + Attribute attr = entry.getValue(); + Expression val = attr.getValue(); + if (val instanceof Literal) { + meta.setEL(key, ((Literal) val).getString()); + } + else { + Struct s = new StructImpl(Struct.TYPE_LINKED); + val.dump(s); + meta.setEL(key, s); + } } } sct.setEL(KeyConstants._metadata, meta); diff --git a/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java b/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java index 4fef6e69399..3e5d6a0a419 100755 --- a/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java +++ b/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java @@ -1093,10 +1093,13 @@ protected final Function closurePart(Data data, String id, int access, int modif // doc comment String hint = null; - if (data.docComment != null) { - func.setHint(data.factory, hint = data.docComment.getHint()); - func.setMetaData(data.docComment.getParams()); - func.setRawDocblock(data.docComment.getRawText()); + DocComment docComment = data.docComment; // save reference before clearing + if (docComment != null) { + hint = docComment.getHint(); + func.setHint(data.factory, hint); + func.setDocblockDescription(hint); // store for annotations.description + func.setAnnotations(docComment.getParams()); + func.setRawDocblock(docComment.getRawText()); data.docComment = null; } @@ -1674,23 +1677,34 @@ private final void addMetaData(Data data, Tag tag, String[] ignoreList) { tag.addMetaData(data.docComment.getHintAsAttribute(data.factory)); - // Store raw docblock for AST round-tripping + // Store raw docblock and annotations for AST round-tripping if (tag instanceof TagBase) { - ((TagBase) tag).setRawDocblock(data.docComment.getRawText()); - } - - Map params = data.docComment.getParams(); - Iterator it = params.values().iterator(); - Attribute attr; - outer: while (it.hasNext()) { - attr = it.next(); - // ignore list - if (!ArrayUtil.isEmpty(ignoreList)) { - for (int i = 0; i < ignoreList.length; i++) { - if (ignoreList[i].equalsIgnoreCase(attr.getName())) continue outer; + TagBase tb = (TagBase) tag; + tb.setRawDocblock(data.docComment.getRawText()); + tb.setDocblockDescription(data.docComment.getHint()); // for annotations.description + // Store docblock @tags as annotations (separate from inline metadata) + Map params = data.docComment.getParams(); + if (params != null && !params.isEmpty()) { + Map filteredParams = new HashMap(); + for (Attribute attr: params.values()) { + // Apply ignore list + boolean ignored = false; + if (!ArrayUtil.isEmpty(ignoreList)) { + for (int i = 0; i < ignoreList.length; i++) { + if (ignoreList[i].equalsIgnoreCase(attr.getName())) { + ignored = true; + break; + } + } + } + if (!ignored) { + filteredParams.put(attr.getName(), attr); + } + } + if (!filteredParams.isEmpty()) { + tb.setAnnotations(filteredParams); } } - tag.addMetaData(attr); } data.docComment = null; } diff --git a/test/tickets/LDEV5990.cfc b/test/tickets/LDEV5990.cfc index edc89dfa753..f9a3c89d542 100644 --- a/test/tickets/LDEV5990.cfc +++ b/test/tickets/LDEV5990.cfc @@ -71,66 +71,40 @@ component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { }); - describe( "LDEV-5990: Docblock hints should include source annotation", function() { + describe( "LDEV-5990: Docblock description goes to annotations.description", function() { - it( "should indicate hint came from docblock vs attribute", function() { + it( "docblock description should be in annotations.description", function() { var ast = astFromPath( variables.testDir & "hintedParams.cfc" ); - // Find the withDocblock function (has hint from docblock) var func = findFunction( ast, "withDocblock" ); expect( func ).notToBeNull( "withDocblock function should be found in AST" ); - expect( func ).toHaveKey( "hint" ); - // Should have some way to know this hint came from a docblock - expect( func ).toHaveKey( "hintSource", "AST should indicate hint source" ); - expect( func.hintSource ).toBe( "docblock" ); + expect( func ).toHaveKey( "docblock", "should have docblock" ); + expect( func ).toHaveKey( "annotations", "should have annotations" ); + expect( func.annotations ).toHaveKey( "description", "annotations should have description" ); + expect( func.annotations.description ).toBe( "This hint comes from a docblock" ); }); - it( "should indicate hint came from attribute when using hint attribute", function() { + it( "inline hint attribute should go to metadata.hint", function() { var ast = astFromPath( variables.testDir & "hintedParams.cfc" ); - // Find the withHintAttr function (has hint="..." attribute) var func = findFunction( ast, "withHintAttr" ); expect( func ).notToBeNull( "withHintAttr function should be found in AST" ); - expect( func ).toHaveKey( "hint" ); - // Should indicate this hint came from an attribute - expect( func ).toHaveKey( "hintSource", "AST should indicate hint source" ); - expect( func.hintSource ).toBe( "attribute" ); - }); - - it( "docblock hints should NOT have quoteChar (original source wasn't quoted)", function() { - var ast = astFromPath( variables.testDir & "hintedParams.cfc" ); - - // Find the withDocblock function (has hint from docblock) - var func = findFunction( ast, "withDocblock" ); - expect( func ).notToBeNull( "withDocblock function should be found in AST" ); - expect( func ).toHaveKey( "hint" ); - expect( func.hintSource ).toBe( "docblock" ); - // Docblock hints should NOT have quoteChar - the original source wasn't quoted - expect( func.hint ).notToHaveKey( "quoteChar", "docblock hints should not have quoteChar" ); - }); - - it( "attribute hints SHOULD have quoteChar (original source was quoted)", function() { - var ast = astFromPath( variables.testDir & "hintedParams.cfc" ); - - // Find the withHintAttr function (has hint="..." attribute) - var func = findFunction( ast, "withHintAttr" ); - expect( func ).notToBeNull( "withHintAttr function should be found in AST" ); - expect( func ).toHaveKey( "hint" ); - expect( func.hintSource ).toBe( "attribute" ); - // Attribute hints SHOULD have quoteChar - the original source was quoted - expect( func.hint ).toHaveKey( "quoteChar", "attribute hints should have quoteChar" ); + expect( func ).notToHaveKey( "docblock", "should not have docblock when hint from attribute" ); + // Inline hint goes to metadata + expect( func ).toHaveKey( "metadata", "should have metadata for inline attributes" ); + expect( func.metadata ).toHaveKey( "hint", "metadata should have hint" ); + expect( func.metadata.hint ).toBe( "This hint comes from an attribute" ); }); }); describe( "LDEV-5990: Raw docblock should be preserved for round-tripping", function() { - it( "should include raw docblock text in AST when hintSource is docblock", function() { + it( "should include raw docblock text when function has docblock", function() { var ast = astFromPath( variables.testDir & "hintedParams.cfc" ); var func = findFunction( ast, "withDocblock" ); expect( func ).notToBeNull( "withDocblock function should be found in AST" ); - expect( func.hintSource ).toBe( "docblock" ); // Raw docblock should be preserved for round-trip fidelity expect( func ).toHaveKey( "docblock", "AST should include raw docblock text" ); expect( func.docblock ).toInclude( "This hint comes from a docblock" ); @@ -139,14 +113,31 @@ component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { expect( func.docblock ).toInclude( "*/", "docblock should include closing */" ); }); - it( "should NOT include docblock key when hint comes from attribute", function() { + it( "should NOT include docblock key when no docblock exists", function() { var ast = astFromPath( variables.testDir & "hintedParams.cfc" ); var func = findFunction( ast, "withHintAttr" ); expect( func ).notToBeNull( "withHintAttr function should be found in AST" ); - expect( func.hintSource ).toBe( "attribute" ); - // No docblock when hint comes from attribute - expect( func ).notToHaveKey( "docblock", "AST should not have docblock when hint is from attribute" ); + // No docblock when hint comes from attribute only + expect( func ).notToHaveKey( "docblock", "AST should not have docblock when none exists" ); + }); + + it( "should preserve docblock EVEN when inline hint attribute also exists", function() { + var ast = astFromPath( variables.testDir & "hintedParams.cfc" ); + + var func = findFunction( ast, "docblockPlusHint" ); + expect( func ).notToBeNull( "docblockPlusHint function should be found in AST" ); + // MUST preserve docblock for round-tripping even when inline hint wins + expect( func ).toHaveKey( "docblock", "docblock must be preserved even with inline hint" ); + expect( func.docblock ).toInclude( "Docblock description" ); + // Docblock description goes to annotations.description + expect( func ).toHaveKey( "annotations" ); + expect( func.annotations ).toHaveKey( "description" ); + expect( func.annotations.description ).toBe( "Docblock description" ); + // Inline hint goes to metadata.hint + expect( func ).toHaveKey( "metadata" ); + expect( func.metadata ).toHaveKey( "hint" ); + expect( func.metadata.hint ).toBe( "Attribute hint" ); }); }); @@ -162,6 +153,10 @@ component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { expect( func.docblock ).toInclude( "Single line docblock" ); expect( func.docblock ).toInclude( "/**" ); expect( func.docblock ).toInclude( "*/" ); + // annotations.description should have the parsed description + expect( func ).toHaveKey( "annotations" ); + expect( func.annotations ).toHaveKey( "description" ); + expect( func.annotations.description ).toBe( "Single line docblock" ); }); it( "should handle docblock with only @tags no description", function() { @@ -169,8 +164,9 @@ component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { var func = findFunction( ast, "onlyTags" ); expect( func ).notToBeNull( "onlyTags function should be found in AST" ); - expect( func ).toHaveKey( "metadata" ); - expect( func.metadata ).toHaveKey( "return" ); + // @tags go into annotations, not metadata + expect( func ).toHaveKey( "annotations" ); + expect( func.annotations ).toHaveKey( "return" ); }); it( "should handle docblock with multiple @param tags", function() { @@ -200,11 +196,12 @@ component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { var func = findFunction( ast, "specialChars" ); expect( func ).notToBeNull( "specialChars function should be found in AST" ); - expect( func ).toHaveKey( "hint" ); - // Hint should preserve special characters - expect( func.hint.value ).toInclude( "" ); - expect( func.hint.value ).toInclude( "&" ); - expect( func.hint.value ).toInclude( """" ); + expect( func ).toHaveKey( "annotations" ); + expect( func.annotations ).toHaveKey( "description" ); + // Description should preserve special characters + expect( func.annotations.description ).toInclude( "" ); + expect( func.annotations.description ).toInclude( "&" ); + expect( func.annotations.description ).toInclude( """" ); }); it( "should handle component-level docblock", function() { @@ -218,32 +215,36 @@ component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { expect( componentTag ).toHaveKey( "docblock" ); expect( componentTag.docblock ).toInclude( "Component-level docblock" ); expect( componentTag.docblock ).toInclude( "@author" ); + // annotations should have description from docblock + expect( componentTag ).toHaveKey( "annotations" ); + expect( componentTag.annotations ).toHaveKey( "description" ); + expect( componentTag.annotations.description ).toBe( "Component-level docblock" ); }); }); describe( "LDEV-5990: Docblock metadata tags should be in AST", function() { - it( "should include @return tag in AST metadata", function() { + it( "should include @return tag in AST annotations", function() { var ast = astFromPath( variables.testDir & "hintedParams.cfc" ); var func = findFunction( ast, "withFullDocblock" ); expect( func ).notToBeNull( "withFullDocblock function should be found in AST" ); - // @return should be accessible in AST - expect( func ).toHaveKey( "metadata", "AST should include metadata from docblock" ); - expect( func.metadata ).toHaveKey( "return", "metadata should include @return" ); - expect( func.metadata.return ).toInclude( "A greeting message" ); + // @return should be in annotations (docblock @tags) + expect( func ).toHaveKey( "annotations", "AST should include annotations from docblock" ); + expect( func.annotations ).toHaveKey( "return", "annotations should include @return" ); + expect( func.annotations.return ).toInclude( "A greeting message" ); }); - it( "should include @deprecated tag in AST metadata", function() { + it( "should include @deprecated tag in AST annotations", function() { var ast = astFromPath( variables.testDir & "hintedParams.cfc" ); var func = findFunction( ast, "withFullDocblock" ); expect( func ).notToBeNull( "withFullDocblock function should be found in AST" ); - // @deprecated should be accessible in AST - expect( func ).toHaveKey( "metadata", "AST should include metadata from docblock" ); - expect( func.metadata ).toHaveKey( "deprecated", "metadata should include @deprecated" ); - expect( func.metadata.deprecated ).toInclude( "Use greetV2 instead" ); + // @deprecated should be in annotations (docblock @tags) + expect( func ).toHaveKey( "annotations", "AST should include annotations from docblock" ); + expect( func.annotations ).toHaveKey( "deprecated", "annotations should include @deprecated" ); + expect( func.annotations.deprecated ).toInclude( "Use greetV2 instead" ); }); it( "should include param hints from docblock @param tags", function() { @@ -328,6 +329,104 @@ component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { }); + describe( "LDEV-5990: Metadata should separate inline attributes from docblock annotations", function() { + + it( "should include inline custom attributes in metadata", function() { + var ast = astFromPath( variables.testDir & "metadataSources.cfc" ); + + var func = findFunction( ast, "inlineAttributeOnly" ); + expect( func ).notToBeNull( "inlineAttributeOnly function should be found in AST" ); + // Inline attributes should be in metadata + expect( func ).toHaveKey( "metadata", "function should have metadata for inline attributes" ); + expect( func.metadata ).toHaveKey( "mixin", "metadata should include mixin attribute" ); + expect( func.metadata.mixin ).toBe( "controller" ); + // No docblock means no annotations field + expect( func ).notToHaveKey( "docblock", "function without docblock should not have docblock key" ); + }); + + it( "should put docblock annotations in annotations field, NOT metadata", function() { + var ast = astFromPath( variables.testDir & "metadataSources.cfc" ); + + var func = findFunction( ast, "docblockAnnotationsOnly" ); + expect( func ).notToBeNull( "docblockAnnotationsOnly function should be found in AST" ); + // Docblock annotations should be in annotations, NOT metadata + expect( func ).toHaveKey( "annotations", "function should have annotations from docblock" ); + expect( func.annotations ).toHaveKey( "changes-only.hint", "annotations should include @changes-only.hint" ); + expect( func.annotations[ "changes-only.hint" ] ).toBe( "Only show differences" ); + // metadata should be empty or not exist (no inline attributes) + if ( structKeyExists( func, "metadata" ) ) { + expect( structIsEmpty( func.metadata ) ).toBeTrue( "metadata should be empty when only docblock annotations exist" ); + } + }); + + it( "should separate inline attributes and docblock annotations when both exist", function() { + var ast = astFromPath( variables.testDir & "metadataSources.cfc" ); + + var func = findFunction( ast, "bothInlineAndDocblock" ); + expect( func ).notToBeNull( "bothInlineAndDocblock function should be found in AST" ); + // Inline attributes go in metadata + expect( func ).toHaveKey( "metadata", "function should have metadata for inline attributes" ); + expect( func.metadata ).toHaveKey( "mixin", "metadata should include mixin" ); + expect( func.metadata.mixin ).toBe( "model" ); + expect( func.metadata ).toHaveKey( "changesOnly", "metadata should include changesOnly" ); + // Docblock annotations go in annotations + expect( func ).toHaveKey( "annotations", "function should have annotations from docblock" ); + expect( func.annotations ).toHaveKey( "someTag.hint", "annotations should include @someTag.hint" ); + // Annotations should NOT be in metadata + expect( func.metadata ).notToHaveKey( "someTag.hint", "docblock annotations should NOT be in metadata" ); + }); + + it( "should preserve raw docblock for round-tripping regardless of annotations", function() { + var ast = astFromPath( variables.testDir & "metadataSources.cfc" ); + + var func = findFunction( ast, "docblockAnnotationsOnly" ); + expect( func ).notToBeNull( "docblockAnnotationsOnly function should be found in AST" ); + // Raw docblock should always be preserved for round-trip + expect( func ).toHaveKey( "docblock", "function should have raw docblock" ); + expect( func.docblock ).toInclude( "@changes-only.hint" ); + expect( func.docblock ).toInclude( "@format.options" ); + }); + + it( "component: should have docblock annotations in annotations field", function() { + var ast = astFromPath( variables.testDir & "metadataSources.cfc" ); + + // Component has BOTH docblock annotations AND inline attributes + var comp = ast.body[ 1 ]; + expect( comp.type ).toBe( "CFMLTag" ); + expect( comp.name ).toBe( "component" ); + // Inline attributes are in attributes array (standard for CFMLTag) + expect( comp ).toHaveKey( "attributes", "component should have attributes array" ); + expect( getTagAttrValue( comp, "displayname" ) ).toBe( "MetadataTest" ); + expect( getTagAttrValue( comp, "singleton" ) ).toBe( "true" ); + // Docblock annotations go in separate annotations field + expect( comp ).toHaveKey( "annotations", "component should have annotations from docblock" ); + // description from docblock first line + expect( comp.annotations ).toHaveKey( "description", "annotations should include description" ); + expect( comp.annotations.description ).toInclude( "Test fixture for LDEV-5990" ); + // @tags from docblock + expect( comp.annotations ).toHaveKey( "author", "annotations should include @author" ); + expect( comp.annotations.author ).toBe( "Test Author" ); + expect( comp.annotations ).toHaveKey( "version", "annotations should include @version" ); + }); + + it( "component: should NOT have annotations when no docblock", function() { + var ast = astFromPath( variables.testDir & "componentInlineOnly.cfc" ); + + var comp = ast.body[ 1 ]; + expect( comp.type ).toBe( "CFMLTag" ); + expect( comp.name ).toBe( "component" ); + // Inline attributes in attributes array + expect( comp ).toHaveKey( "attributes", "component should have attributes" ); + expect( getTagAttrValue( comp, "displayname" ) ).toBe( "InlineOnlyComponent" ); + expect( getTagAttrValue( comp, "singleton" ) ).toBe( "true" ); + expect( getTagAttrValue( comp, "accessors" ) ).toBe( "true" ); + // No docblock means no annotations + expect( comp ).notToHaveKey( "annotations", "component without docblock should not have annotations" ); + expect( comp ).notToHaveKey( "docblock", "component without docblock should not have docblock" ); + }); + + }); + } /** diff --git a/test/tickets/LDEV5990/hintedParams.cfc b/test/tickets/LDEV5990/hintedParams.cfc index 8fce8c5d16c..9ae5610a541 100644 --- a/test/tickets/LDEV5990/hintedParams.cfc +++ b/test/tickets/LDEV5990/hintedParams.cfc @@ -25,8 +25,30 @@ component { return "docblock"; } + /** + * Function with full docblock annotations + * @name The name parameter description + * @age The age parameter description + * @return string A greeting message + * @deprecated Use greetV2 instead + */ + function withFullDocblock( string name, numeric age ) { + return "Hello #name#, you are #age#"; + } + function withHintAttr() hint="This hint comes from an attribute" { return "attribute"; } + /** + * Docblock description + */ + function docblockPlusHint() hint="Attribute hint" { + return "both"; + } + + remote function remoteFunc() returnformat="json" { + return { "status": "ok" }; + } + } From 27490ad73bbd867a3a5fe6e0779333533f7ba2a3 Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Mon, 22 Dec 2025 00:54:47 +0100 Subject: [PATCH 58/71] LDEV-6036 mark default type=any as default attribute in script properties --- .../script/AbstrCFMLScriptTransformer.java | 4 +- test/tickets/LDEV6036.cfc | 221 ++++++++++++++++++ test/tickets/LDEV6036/propertyNoType.cfc | 8 + test/tickets/LDEV6036/propertyNoTypeTag.cfc | 8 + 4 files changed, 240 insertions(+), 1 deletion(-) create mode 100644 test/tickets/LDEV6036.cfc create mode 100644 test/tickets/LDEV6036/propertyNoType.cfc create mode 100644 test/tickets/LDEV6036/propertyNoTypeTag.cfc diff --git a/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java b/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java index 3e5d6a0a419..cccd98dfb2e 100755 --- a/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java +++ b/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java @@ -1815,7 +1815,9 @@ else if (second == null && !hasName && !hasType) { } if (!hasType) { - property.addAttribute(new Attribute(false, "type", data.factory.createLitString("any"), "string")); + Attribute typeAttr = new Attribute(false, "type", data.factory.createLitString("any"), "string"); + typeAttr.setDefaultAttribute(true); + property.addAttribute(typeAttr); } if (!hasName) throw new TemplateException(data.srcCode, "missing name declaration for property"); diff --git a/test/tickets/LDEV6036.cfc b/test/tickets/LDEV6036.cfc new file mode 100644 index 00000000000..7832ff2a82a --- /dev/null +++ b/test/tickets/LDEV6036.cfc @@ -0,0 +1,221 @@ +component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { + + variables.testDir = getDirectoryFromPath( getCurrentTemplatePath() ) & "LDEV6036/"; + + function run( testResults, testBox ) { + describe( "LDEV-6036: AST should not add default attribute values", function() { + + describe( "Script-style properties", function() { + + it( "property without type should NOT have type attribute in AST", function() { + var ast = astFromPath( variables.testDir & "propertyNoType.cfc" ); + + // Find the first property (no type in source) + var prop = findProperty( ast, "Mixins" ); + expect( prop ).notToBeNull( "Mixins property should be found in AST" ); + + // Convert to struct for easier checking + var attrs = attrsToStruct( prop ); + + // Should have name and inject only + expect( attrs ).toHaveKey( "name", "property should have name attribute" ); + expect( attrs.name.value ).toBe( "Mixins" ); + + expect( attrs ).toHaveKey( "inject", "property should have inject attribute" ); + expect( attrs.inject.value ).toBe( "id:Plugins" ); + + // Check attributes - should NOT contain type="any" + expect( attrs ).notToHaveKey( "type", "property without type in source should NOT have type attribute in AST" ); + }); + + it( "property WITH explicit type should have type attribute in AST", function() { + var ast = astFromPath( variables.testDir & "propertyNoType.cfc" ); + + // Find the second property (has type="string" in source) + var prop = findProperty( ast, "WithType" ); + expect( prop ).notToBeNull( "WithType property should be found in AST" ); + + // Convert to struct for easier checking + var attrs = attrsToStruct( prop ); + + // Should have type attribute since it was in source + expect( attrs ).toHaveKey( "type", "property with type in source should have type attribute in AST" ); + expect( attrs.type.value ).toBe( "string" ); + }); + + it( "property WITH explicit type='any' should preserve type attribute with quoteChar", function() { + var ast = astFromPath( variables.testDir & "propertyNoType.cfc" ); + + // Find the property with explicit type="any" + var prop = findProperty( ast, "ExplicitAny" ); + expect( prop ).notToBeNull( "ExplicitAny property should be found in AST" ); + + // Convert to struct for easier checking + var attrs = attrsToStruct( prop ); + + // Should have type attribute since it was explicitly in source + expect( attrs ).toHaveKey( "type", "property with explicit type='any' should have type attribute" ); + expect( attrs.type.value ).toBe( "any" ); + // CRITICAL: explicit type="any" should have quoteChar (proving it came from source) + expect( attrs.type ).toHaveKey( "quoteChar", "explicit type='any' should have quoteChar (from source)" ); + }); + + it( "only source attributes should have quoteChar", function() { + var ast = astFromPath( variables.testDir & "propertyNoType.cfc" ); + + var prop = findProperty( ast, "Mixins" ); + expect( prop ).notToBeNull( "Mixins property should be found in AST" ); + + // Convert to struct for easier checking + var attrs = attrsToStruct( prop ); + + // Source attributes should have quoteChar + expect( attrs ).toHaveKey( "name" ); + expect( attrs.name ).toHaveKey( "quoteChar", "source attribute should have quoteChar" ); + + // If type exists (bug), it should NOT have quoteChar (proving it's a default) + if ( structKeyExists( attrs, "type" ) ) { + // If we get here, the bug exists - the default was added + // Check that at least it lacks quoteChar (confirming it's not from source) + expect( attrs.type ).notToHaveKey( "quoteChar", + "default type attribute should NOT have quoteChar (proving it was added, not from source)" + ); + } + }); + + }); + + describe( "Tag-style properties", function() { + + it( "cfproperty without type should NOT have type attribute in AST", function() { + var ast = astFromPath( variables.testDir & "propertyNoTypeTag.cfc" ); + + // Find the first property (no type in source) + var prop = findProperty( ast, "TagMixins" ); + expect( prop ).notToBeNull( "TagMixins property should be found in AST" ); + + // Convert to struct for easier checking + var attrs = attrsToStruct( prop ); + + // Should have name and inject only + expect( attrs ).toHaveKey( "name", "property should have name attribute" ); + expect( attrs.name.value ).toBe( "TagMixins" ); + + expect( attrs ).toHaveKey( "inject", "property should have inject attribute" ); + expect( attrs.inject.value ).toBe( "id:Plugins" ); + + // Check attributes - should NOT contain type="any" + expect( attrs ).notToHaveKey( "type", "cfproperty without type in source should NOT have type attribute in AST" ); + }); + + it( "cfproperty WITH explicit type should have type attribute in AST", function() { + var ast = astFromPath( variables.testDir & "propertyNoTypeTag.cfc" ); + + // Find the second property (has type="string" in source) + var prop = findProperty( ast, "TagWithType" ); + expect( prop ).notToBeNull( "TagWithType property should be found in AST" ); + + // Convert to struct for easier checking + var attrs = attrsToStruct( prop ); + + // Should have type attribute since it was in source + expect( attrs ).toHaveKey( "type", "cfproperty with type in source should have type attribute in AST" ); + expect( attrs.type.value ).toBe( "string" ); + }); + + it( "cfproperty WITH explicit type='any' should preserve type attribute with quoteChar", function() { + var ast = astFromPath( variables.testDir & "propertyNoTypeTag.cfc" ); + + // Find the property with explicit type="any" + var prop = findProperty( ast, "TagExplicitAny" ); + expect( prop ).notToBeNull( "TagExplicitAny property should be found in AST" ); + + // Convert to struct for easier checking + var attrs = attrsToStruct( prop ); + + // Should have type attribute since it was explicitly in source + expect( attrs ).toHaveKey( "type", "cfproperty with explicit type='any' should have type attribute" ); + expect( attrs.type.value ).toBe( "any" ); + // CRITICAL: explicit type="any" should have quoteChar (proving it came from source) + expect( attrs.type ).toHaveKey( "quoteChar", "explicit type='any' should have quoteChar (from source)" ); + }); + + }); + + }); + } + + /** + * Find a property by name in the AST + */ + private function findProperty( required struct node, required string name ) { + var nodeType = node.type ?: ""; + + // Check if this is a property with matching name + if ( nodeType == "CFMLTag" && ( node.name ?: "" ) == "property" ) { + var nameAttr = getAttr( node, "name" ); + if ( !isNull( nameAttr ) && uCase( nameAttr.value.value ?: "" ) == uCase( name ) ) { + return node; + } + } + + // Recurse into body + if ( structKeyExists( node, "body" ) ) { + if ( isStruct( node.body ) ) { + var result = findProperty( node.body, name ); + if ( !isNull( result ) ) return result; + } else if ( isArray( node.body ) ) { + for ( var child in node.body ) { + if ( isStruct( child ) ) { + var result = findProperty( child, name ); + if ( !isNull( result ) ) return result; + } + } + } + } + + // Recurse into body.body (for components) + if ( structKeyExists( node, "body" ) && isStruct( node.body ) && structKeyExists( node.body, "body" ) ) { + if ( isArray( node.body.body ) ) { + for ( var child in node.body.body ) { + if ( isStruct( child ) ) { + var result = findProperty( child, name ); + if ( !isNull( result ) ) return result; + } + } + } + } + + return; + } + + /** + * Get an attribute from a tag node by name + */ + private function getAttr( required struct node, required string name ) { + if ( !structKeyExists( node, "attributes" ) || !isArray( node.attributes ) ) { + return; + } + for ( var attr in node.attributes ) { + if ( uCase( attr.name ?: "" ) == uCase( name ) ) { + return attr; + } + } + return; + } + + /** + * Convert attributes array to struct keyed by attribute name + */ + private struct function attrsToStruct( required struct node ) { + var result = {}; + if ( !structKeyExists( node, "attributes" ) || !isArray( node.attributes ) ) { + return result; + } + for ( var attr in node.attributes ) { + result[ attr.name ] = attr.value; + } + return result; + } + +} diff --git a/test/tickets/LDEV6036/propertyNoType.cfc b/test/tickets/LDEV6036/propertyNoType.cfc new file mode 100644 index 00000000000..fbb56630949 --- /dev/null +++ b/test/tickets/LDEV6036/propertyNoType.cfc @@ -0,0 +1,8 @@ +component { + // Script style - no type attribute + property name="Mixins" inject="id:Plugins"; + // Script style - WITH explicit type + property name="WithType" type="string" inject="id:Config"; + // Script style - WITH explicit type="any" (should be preserved!) + property name="ExplicitAny" type="any" inject="id:Service"; +} diff --git a/test/tickets/LDEV6036/propertyNoTypeTag.cfc b/test/tickets/LDEV6036/propertyNoTypeTag.cfc new file mode 100644 index 00000000000..f1e0b6f3d9c --- /dev/null +++ b/test/tickets/LDEV6036/propertyNoTypeTag.cfc @@ -0,0 +1,8 @@ + + + + + + + + From 33ab638a2b8c9ae138c0b42708ed860843515601 Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Mon, 22 Dec 2025 14:53:21 +0100 Subject: [PATCH 59/71] LDEV-6011 preserve :: static method syntax in AST output --- .../bytecode/expression/var/VariableImpl.java | 114 ++++++++++++++++-- test/tickets/LDEV6011.cfc | 77 ++++++++++++ test/tickets/LDEV6011/StaticClass.cfc | 11 ++ test/tickets/LDEV6011/dotCall.cfm | 3 + test/tickets/LDEV6011/dotProperty.cfm | 3 + test/tickets/LDEV6011/doubleColonCall.cfm | 3 + test/tickets/LDEV6011/doubleColonProperty.cfm | 3 + 7 files changed, 205 insertions(+), 9 deletions(-) create mode 100644 test/tickets/LDEV6011/StaticClass.cfc create mode 100644 test/tickets/LDEV6011/dotCall.cfm create mode 100644 test/tickets/LDEV6011/dotProperty.cfm create mode 100644 test/tickets/LDEV6011/doubleColonCall.cfm create mode 100644 test/tickets/LDEV6011/doubleColonProperty.cfm diff --git a/core/src/main/java/lucee/transformer/bytecode/expression/var/VariableImpl.java b/core/src/main/java/lucee/transformer/bytecode/expression/var/VariableImpl.java index b3cc234d410..cd0c55dd378 100644 --- a/core/src/main/java/lucee/transformer/bytecode/expression/var/VariableImpl.java +++ b/core/src/main/java/lucee/transformer/bytecode/expression/var/VariableImpl.java @@ -1252,7 +1252,16 @@ else if ("_createComponent".equalsIgnoreCase(funcName)) { if (member.getSafeNavigated()) { callee.setEL(KeyConstants._optional, Boolean.TRUE); } - callee.setEL(KeyConstants._object, current); + + // LDEV-6011: Handle _getstaticscope/_getsuperstaticscope for :: syntax + Struct staticInfo = extractStaticScopeInfo(current); + if (staticInfo != null) { + callee.setEL(KeyConstants._static, Boolean.TRUE); + callee.setEL(KeyConstants._object, staticInfo); + } + else { + callee.setEL(KeyConstants._object, current); + } if (isComputedCall) { // Computed property access - use the dumped expression @@ -1297,16 +1306,20 @@ else if ("_createComponent".equalsIgnoreCase(funcName)) { if (member.getSafeNavigated()) { newNode.setEL(KeyConstants._optional, Boolean.TRUE); } - newNode.setEL(KeyConstants._object, current); - Struct property = new StructImpl(Struct.TYPE_LINKED); - if (isComputed && memberName instanceof Literal) { - // Bracket notation with literal key - output as StringLiteral - property.setEL(KeyConstants._type, "StringLiteral"); - property.setEL(KeyConstants._value, memberName.toString()); + // LDEV-6011: Handle _getstaticscope/_getsuperstaticscope for :: syntax + Struct staticInfo = extractStaticScopeInfo(current); + if (staticInfo != null) { + newNode.setEL(KeyConstants._static, Boolean.TRUE); + newNode.setEL(KeyConstants._object, staticInfo); + } + else { + newNode.setEL(KeyConstants._object, current); } - else if (isComputed) { - // Bracket notation with dynamic expression - dump the expression + + Struct property = new StructImpl(Struct.TYPE_LINKED); + if (isComputed) { + // Bracket notation - dump the expression to preserve quoteChar etc. memberName.dump(property); } else { @@ -1334,6 +1347,89 @@ private static Object getName(NamedMember dm) { return name; } + /** + * LDEV-6011: Check if the current AST node represents a _getstaticscope or _getsuperstaticscope call. + * If so, extract the class/component name and return it as an Identifier node. + * This preserves the :: syntax in the AST output instead of exposing internal function names. + * + * @param current The current AST Struct being built + * @return An Identifier Struct with the class name, or null if not a static scope call + */ + private static Struct extractStaticScopeInfo(Struct current) { + if (current == null) return null; + + // Check if this is a CallExpression + Object type = current.get(KeyConstants._type, null); + if (!"CallExpression".equals(type)) return null; + + // Get the callee + Object calleeObj = current.get(KeyConstants._callee, null); + if (!(calleeObj instanceof Struct)) return null; + Struct callee = (Struct) calleeObj; + + // Check if callee is an Identifier + Object calleeType = callee.get(KeyConstants._type, null); + if (!"Identifier".equals(calleeType)) return null; + + // Check the function name + Object nameObj = callee.get(KeyConstants._name, null); + if (nameObj == null) return null; + String funcName = nameObj.toString().toLowerCase(); + + Struct identifier = new StructImpl(Struct.TYPE_LINKED); + identifier.setEL(KeyConstants._type, "Identifier"); + + if ("_getsuperstaticscope".equals(funcName)) { + // super::method() - no arguments, just return "super" identifier + identifier.setEL(KeyConstants._name, "super"); + return identifier; + } + else if ("_getstaticscope".equals(funcName)) { + // Class::method() - first argument is the class/component name + Object argsObj = current.get(KeyConstants._arguments, null); + if (!(argsObj instanceof Array)) return null; + Array args = (Array) argsObj; + if (args.size() < 1) return null; + + // Get the first argument (class name) + Object firstArg = args.get(1, null); + if (!(firstArg instanceof Struct)) return null; + Struct argStruct = (Struct) firstArg; + + // Extract the value (should be a StringLiteral) + Object argType = argStruct.get(KeyConstants._type, null); + if (!"StringLiteral".equals(argType)) return null; + + Object valueObj = argStruct.get(KeyConstants._value, null); + if (valueObj == null) return null; + + // Check if there's a second argument indicating java: prefix + boolean hasJavaPrefix = false; + if (args.size() >= 2) { + Object secondArg = args.get(2, null); + if (secondArg instanceof Struct) { + Struct secondArgStruct = (Struct) secondArg; + Object secondValue = secondArgStruct.get(KeyConstants._value, null); + if ("java".equals(secondValue)) { + hasJavaPrefix = true; + } + } + } + + // Build the identifier name, preserving java: prefix if present + String className = valueObj.toString(); + if (hasJavaPrefix) { + identifier.setEL(KeyConstants._name, "java:" + className); + } + else { + identifier.setEL(KeyConstants._name, className); + } + return identifier; + } + + return null; + } + } class VT { diff --git a/test/tickets/LDEV6011.cfc b/test/tickets/LDEV6011.cfc index 0d75c5b9a2c..78069ddc5aa 100644 --- a/test/tickets/LDEV6011.cfc +++ b/test/tickets/LDEV6011.cfc @@ -241,6 +241,83 @@ component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { "Third arg 'c: 3' should have separator=':' but got '#args[3].separator ?: 'null'#'" ); }); + it( "should represent Class::method() as static MemberExpression not _getstaticscope", function() { + var code = 'x = MyClass::staticMethod( arg );'; + var ast = astFromString( code, "script" ); + + expect( ast.body ).toHaveLength( 1 ); + var right = ast.body[1].right; + expect( right.type ).toBe( "CallExpression" ); + + // The callee should be a MemberExpression with static=true + var callee = right.callee; + expect( callee.type ).toBe( "MemberExpression", + "Expected MemberExpression but got #callee.type#" ); + expect( callee ).toHaveKey( "static", + "MemberExpression should have 'static' field for :: syntax" ); + expect( callee.static ).toBe( true, + "Expected static=true for :: syntax but got #callee.static ?: 'null'#" ); + + // The object should be the class name, not _getstaticscope call + expect( callee.object.type ).toBe( "Identifier", + "Expected object to be Identifier but got #callee.object.type#" & + ( callee.object.type == "CallExpression" ? " with callee #callee.object.callee.name ?: 'unknown'#" : "" ) ); + expect( callee.object.name ).toBe( "MyClass", + "Expected object name 'MyClass' but got '#callee.object.name ?: 'null'#'" ); + + // The property should be the method name + expect( callee.property.name ).toBe( "staticMethod", + "Expected property name 'staticMethod' but got '#callee.property.name ?: 'null'#'" ); + }); + + it( "should represent super::method() as static MemberExpression not _getsuperstaticscope", function() { + var code = 'x = super::parentMethod();'; + var ast = astFromString( code, "script" ); + + expect( ast.body ).toHaveLength( 1 ); + var right = ast.body[1].right; + expect( right.type ).toBe( "CallExpression" ); + + // The callee should be a MemberExpression with static=true + var callee = right.callee; + expect( callee.type ).toBe( "MemberExpression" ); + expect( callee ).toHaveKey( "static" ); + expect( callee.static ).toBe( true ); + + // The object should be "super" identifier, not _getsuperstaticscope call + expect( callee.object.type ).toBe( "Identifier", + "Expected object to be Identifier but got #callee.object.type#" & + ( callee.object.type == "CallExpression" ? " with callee #callee.object.callee.name ?: 'unknown'#" : "" ) ); + expect( callee.object.name ).toBe( "super", + "Expected object name 'super' but got '#callee.object.name ?: 'null'#'" ); + + // The property should be the method name + expect( callee.property.name ).toBe( "parentMethod" ); + }); + + it( "should represent Class::CONSTANT as static MemberExpression", function() { + var code = 'x = MyClass::CONSTANT;'; + var ast = astFromString( code, "script" ); + + expect( ast.body ).toHaveLength( 1 ); + var right = ast.body[1].right; + + // Direct static property access (not a CallExpression) + expect( right.type ).toBe( "MemberExpression", + "Expected MemberExpression but got #right.type#" ); + expect( right ).toHaveKey( "static" ); + expect( right.static ).toBe( true ); + + // The object should be the class name + expect( right.object.type ).toBe( "Identifier", + "Expected object to be Identifier but got #right.object.type#" & + ( right.object.type == "CallExpression" ? " with callee #right.object.callee.name ?: 'unknown'#" : "" ) ); + expect( right.object.name ).toBe( "MyClass" ); + + // The property should be the constant name + expect( right.property.name ).toBe( "CONSTANT" ); + }); + }); } diff --git a/test/tickets/LDEV6011/StaticClass.cfc b/test/tickets/LDEV6011/StaticClass.cfc new file mode 100644 index 00000000000..7c07e7ff147 --- /dev/null +++ b/test/tickets/LDEV6011/StaticClass.cfc @@ -0,0 +1,11 @@ +component { + + static { + static.CONSTANT = "static_constant_value"; + } + + public static function staticMethod( required string arg ) { + return "called with: " & arguments.arg; + } + +} diff --git a/test/tickets/LDEV6011/dotCall.cfm b/test/tickets/LDEV6011/dotCall.cfm new file mode 100644 index 00000000000..faa5c2f6bbd --- /dev/null +++ b/test/tickets/LDEV6011/dotCall.cfm @@ -0,0 +1,3 @@ + +result = StaticClass.staticMethod( "test" ); + diff --git a/test/tickets/LDEV6011/dotProperty.cfm b/test/tickets/LDEV6011/dotProperty.cfm new file mode 100644 index 00000000000..8051f84c435 --- /dev/null +++ b/test/tickets/LDEV6011/dotProperty.cfm @@ -0,0 +1,3 @@ + +result = StaticClass.CONSTANT; + diff --git a/test/tickets/LDEV6011/doubleColonCall.cfm b/test/tickets/LDEV6011/doubleColonCall.cfm new file mode 100644 index 00000000000..f70120bc344 --- /dev/null +++ b/test/tickets/LDEV6011/doubleColonCall.cfm @@ -0,0 +1,3 @@ + +result = StaticClass::staticMethod( "test" ); + diff --git a/test/tickets/LDEV6011/doubleColonProperty.cfm b/test/tickets/LDEV6011/doubleColonProperty.cfm new file mode 100644 index 00000000000..50be2ab0eb8 --- /dev/null +++ b/test/tickets/LDEV6011/doubleColonProperty.cfm @@ -0,0 +1,3 @@ + +result = StaticClass::CONSTANT; + From 1358f0dd2bc7a16a5da98388f53e65aa35432b58 Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Mon, 22 Dec 2025 16:51:52 +0100 Subject: [PATCH 60/71] LDEV-5990 fix docblock attached to closure default value instead of outer function When a function param has a closure as default value, the docblock was incorrectly consumed by the inner closure instead of the outer function. Fix: save/restore docComment around closure parsing in AbstrCFMLExprTransformer, and skip docComment attachment for closures in AbstrCFMLScriptTransformer. --- .../expression/AbstrCFMLExprTransformer.java | 6 +++++ .../script/AbstrCFMLScriptTransformer.java | 20 ++++++++------- test/tickets/LDEV5990.cfc | 25 +++++++++++++++++++ test/tickets/LDEV5990/hintedParams.cfc | 7 ++++++ 4 files changed, 49 insertions(+), 9 deletions(-) diff --git a/core/src/main/java/lucee/transformer/cfml/expression/AbstrCFMLExprTransformer.java b/core/src/main/java/lucee/transformer/cfml/expression/AbstrCFMLExprTransformer.java index 24baa3b88a7..ce98459ac83 100755 --- a/core/src/main/java/lucee/transformer/cfml/expression/AbstrCFMLExprTransformer.java +++ b/core/src/main/java/lucee/transformer/cfml/expression/AbstrCFMLExprTransformer.java @@ -58,6 +58,7 @@ import lucee.transformer.bytecode.statement.udf.Function; import lucee.transformer.bytecode.util.ASMUtil; import lucee.transformer.cfml.Data; +import lucee.transformer.cfml.script.DocComment; import lucee.transformer.cfml.script.DocCommentTransformer; import lucee.transformer.cfml.tag.CFMLTransformer; import lucee.transformer.expression.ExprBoolean; @@ -1405,7 +1406,12 @@ protected Expression json(Data data, FunctionLibFunction flf, char start, char e private Expression closure(Data data) throws TemplateException { if (!data.srcCode.forwardIfCurrent("function", '(')) return null; data.srcCode.previous(); + // Save docComment - inline closures (e.g. in default param values) should not consume + // the docblock that belongs to the outer function + DocComment savedDocComment = data.docComment; Function func = closurePart(data, "closure_" + CreateUniqueId.invoke(), Component.ACCESS_PUBLIC, Component.MODIFIER_NONE, "any", data.srcCode.getPosition(), true); + // Restore docComment after closure parsing (closurePart and statement() may have cleared it) + data.docComment = savedDocComment; func.setParent(data.getParent()); return new FunctionAsExpression(func); } diff --git a/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java b/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java index cccd98dfb2e..143d02d570f 100755 --- a/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java +++ b/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java @@ -1091,16 +1091,18 @@ protected final Function closurePart(Data data, String id, int access, int modif // TagLibTag tlt = CFMLTransformer.getTLT(data.srcCode,"function"); - // doc comment + // doc comment - only attach to named functions, not inline closures String hint = null; - DocComment docComment = data.docComment; // save reference before clearing - if (docComment != null) { - hint = docComment.getHint(); - func.setHint(data.factory, hint); - func.setDocblockDescription(hint); // store for annotations.description - func.setAnnotations(docComment.getParams()); - func.setRawDocblock(docComment.getRawText()); - data.docComment = null; + if (!closure) { + DocComment docComment = data.docComment; // save reference before clearing + if (docComment != null) { + hint = docComment.getHint(); + func.setHint(data.factory, hint); + func.setDocblockDescription(hint); // store for annotations.description + func.setAnnotations(docComment.getParams()); + func.setRawDocblock(docComment.getRawText()); + data.docComment = null; + } } comments(data); diff --git a/test/tickets/LDEV5990.cfc b/test/tickets/LDEV5990.cfc index f9a3c89d542..51492cd8428 100644 --- a/test/tickets/LDEV5990.cfc +++ b/test/tickets/LDEV5990.cfc @@ -223,6 +223,31 @@ component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { }); + describe( "LDEV-5990: Docblock should NOT attach to closure default value", function() { + + it( "should attach docblock to outer function, not closure default value", function() { + var ast = astFromPath( variables.testDir & "hintedParams.cfc" ); + + var func = findFunction( ast, "withClosureDefault" ); + expect( func ).notToBeNull( "withClosureDefault function should be found in AST" ); + + // The docblock should be on the OUTER function + expect( func ).toHaveKey( "docblock", "outer function should have docblock" ); + expect( func.docblock ).toInclude( "@cb.hint" ); + + // The closure default value should NOT have the docblock + var param = func.params[ 1 ]; + expect( param.name.value ).toBe( "cb" ); + expect( param ).toHaveKey( "defaultValue", "param should have defaultValue" ); + var closure = param.defaultValue; + expect( closure.type ).toBeWithCase( "ClosureDeclaration" ); + // BUG: docblock is incorrectly attached to closure instead of outer function + expect( closure ).notToHaveKey( "docblock", "closure default value should NOT have docblock" ); + expect( closure ).notToHaveKey( "annotations", "closure default value should NOT have annotations" ); + }); + + }); + describe( "LDEV-5990: Docblock metadata tags should be in AST", function() { it( "should include @return tag in AST annotations", function() { diff --git a/test/tickets/LDEV5990/hintedParams.cfc b/test/tickets/LDEV5990/hintedParams.cfc index 9ae5610a541..317625270f0 100644 --- a/test/tickets/LDEV5990/hintedParams.cfc +++ b/test/tickets/LDEV5990/hintedParams.cfc @@ -51,4 +51,11 @@ component { return { "status": "ok" }; } + /** + * @cb.hint Callback function hint + */ + function withClosureDefault( function cb=function(){} ) { + return cb(); + } + } From 15bdff33ee2c49c6cbac2ee8a25fac386e8ff398 Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Mon, 22 Dec 2025 16:59:30 +0100 Subject: [PATCH 61/71] LDEV-6015 ExpressionInvoker dump() for member expression chains --- .../expression/ExpressionInvoker.java | 89 ++++++++- test/tickets/LDEV6015.cfc | 176 ++++++++++++++++++ test/tickets/LDEV6015/unquotedNumericAttr.cfc | 3 + 3 files changed, 267 insertions(+), 1 deletion(-) create mode 100644 test/tickets/LDEV6015/unquotedNumericAttr.cfc diff --git a/core/src/main/java/lucee/transformer/bytecode/expression/ExpressionInvoker.java b/core/src/main/java/lucee/transformer/bytecode/expression/ExpressionInvoker.java index 8ba8795f2c4..2c102eb4bab 100755 --- a/core/src/main/java/lucee/transformer/bytecode/expression/ExpressionInvoker.java +++ b/core/src/main/java/lucee/transformer/bytecode/expression/ExpressionInvoker.java @@ -25,14 +25,23 @@ import org.objectweb.asm.commons.GeneratorAdapter; import org.objectweb.asm.commons.Method; +import lucee.runtime.type.Array; +import lucee.runtime.type.ArrayImpl; +import lucee.runtime.type.Collection.Key; import lucee.runtime.type.Struct; +import lucee.runtime.type.StructImpl; +import lucee.runtime.type.util.KeyConstants; import lucee.transformer.TransformerException; import lucee.transformer.bytecode.BytecodeContext; +import lucee.transformer.bytecode.expression.var.FunctionMember; import lucee.transformer.bytecode.expression.var.UDF; +import lucee.transformer.expression.var.Argument; import lucee.transformer.bytecode.util.ExpressionUtil; import lucee.transformer.bytecode.util.Types; import lucee.transformer.expression.Expression; import lucee.transformer.expression.Invoker; +import lucee.transformer.expression.literal.LitString; +import lucee.transformer.expression.literal.Literal; import lucee.transformer.expression.var.DataMember; import lucee.transformer.expression.var.Member; import lucee.transformer.expression.var.Variable; @@ -123,6 +132,84 @@ public void addListener(Expression listener) { @Override public void dump(Struct sct) { - expr.dump(sct); + // If no members, just dump the wrapped expression + if (members.isEmpty()) { + expr.dump(sct); + return; + } + + // Build the member expression chain iteratively + // Start with the base expression + Struct current = new StructImpl(Struct.TYPE_LINKED); + expr.dump(current); + + // Add each member to the chain + for (Member member : members) { + Struct newNode = new StructImpl(Struct.TYPE_LINKED); + + if (member instanceof FunctionMember) { + FunctionMember fm = (FunctionMember) member; + Expression nameExpr = fm.getName(); + String funcName = nameExpr.toString(); + + // Function call - create CallExpression + newNode.setEL(KeyConstants._type, "CallExpression"); + + // Create MemberExpression for callee (object.method) + Struct callee = new StructImpl(Struct.TYPE_LINKED); + callee.setEL(KeyConstants._type, "MemberExpression"); + callee.setEL(KeyConstants._computed, Boolean.FALSE); + callee.setEL(KeyConstants._object, current); + + Struct property = new StructImpl(Struct.TYPE_LINKED); + property.setEL(KeyConstants._type, "Identifier"); + property.setEL(KeyConstants._name, funcName); + callee.setEL(KeyConstants._property, property); + + newNode.setEL(KeyConstants._callee, callee); + + // Add arguments + Array arrArgs = new ArrayImpl(); + newNode.setEL(KeyConstants._arguments, arrArgs); + for (Argument arg : fm.getSourceArguments()) { + Struct sctArg = new StructImpl(Struct.TYPE_LINKED); + arrArgs.appendEL(sctArg); + arg.dump(sctArg); + } + } + else if (member instanceof DataMember) { + // Property access - create MemberExpression + DataMember dm = (DataMember) member; + newNode.setEL(KeyConstants._type, "MemberExpression"); + + Expression memberName = dm.getName(); + boolean isComputed = (memberName instanceof LitString && ((LitString) memberName).fromBracket()) + || !(memberName instanceof Literal); + newNode.setEL(KeyConstants._computed, isComputed); + newNode.setEL(KeyConstants._object, current); + + Struct property = new StructImpl(Struct.TYPE_LINKED); + if (isComputed && memberName instanceof Literal) { + property.setEL(KeyConstants._type, "StringLiteral"); + property.setEL(KeyConstants._value, memberName.toString()); + } + else if (isComputed) { + memberName.dump(property); + } + else { + property.setEL(KeyConstants._type, "Identifier"); + property.setEL(KeyConstants._name, memberName.toString()); + } + newNode.setEL(KeyConstants._property, property); + } + + current = newNode; + } + + // Copy the final result to the output struct + Key[] keys = current.keys(); + for (Key key : keys) { + sct.setEL(key, current.get(key, null)); + } } } \ No newline at end of file diff --git a/test/tickets/LDEV6015.cfc b/test/tickets/LDEV6015.cfc index 0712fd568bc..b7bc8638693 100644 --- a/test/tickets/LDEV6015.cfc +++ b/test/tickets/LDEV6015.cfc @@ -70,6 +70,182 @@ component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { expect( templateLiteral.quoteChar ).toBe( "'", "Should preserve single quote for interpolated string" ); }); + it( "should preserve quoteChar in inline function param hint", function() { + // Inline param hints (hint="...") should have quoteChar + var ast = astFromString( 'function test( string name hint="my description" ) {}', "script" ); + var param = ast.body[1].params[1]; + expect( param.hint.type ).toBe( "StringLiteral" ); + expect( param.hint.value ).toBe( "my description" ); + expect( param.hint ).toHaveKey( "quoteChar", "inline param hint should have quoteChar" ); + expect( param.hint.quoteChar ).toBe( '"', "Should preserve double quote in param hint" ); + }); + + it( "should NOT have quoteChar for docblock param hint", function() { + // Docblock @param hints are unquoted in source, so no quoteChar + var code = '/**' & chr(10) & + ' * @name The name param' & chr(10) & + ' */' & chr(10) & + 'function test( string name ) {}'; + var ast = astFromString( code, "script" ); + var param = ast.body[1].params[1]; + expect( param.hint.type ).toBe( "StringLiteral" ); + expect( param.hint.value ).toBe( "The name param" ); + // Docblock hints should NOT have quoteChar since they're unquoted in source + expect( param.hint ).notToHaveKey( "quoteChar", "docblock param hint should not have quoteChar" ); + }); + + it( "should NOT have quoteChar for unquoted numeric attribute coerced to StringLiteral", function() { + // When width=100 is parsed, Lucee coerces it to StringLiteral + // But since the original source had no quotes, quoteChar should NOT be set + // The raw field shows quotes but that's just the string representation, not source-level info + var ast = astFromPath( variables.testDir & "unquotedNumericAttr.cfc" ); + var comp = ast.body[1]; + var cfimageTag = comp.body.body[1]; + expect( cfimageTag.type ).toBe( "CFMLTag" ); + expect( cfimageTag.name ).toBe( "image" ); + + // Find the width attribute + var widthAttr = cfimageTag.attributes.filter( function( a ) { return a.name == "width"; } )[1]; + expect( widthAttr ).toBeStruct( "width attribute should exist" ); + expect( widthAttr.value.type ).toBe( "StringLiteral", "unquoted numeric should become StringLiteral" ); + expect( widthAttr.value.value ).toBe( "100" ); + // Since original source was unquoted, quoteChar should NOT be present + expect( widthAttr.value ).notToHaveKey( "quoteChar", "StringLiteral from unquoted source should not have quoteChar" ); + }); + + it( "should preserve quoteChar for bracket notation string key with double quotes", function() { + // struct["key"] - the string key should have quoteChar + var ast = astFromString( 'x = myStruct["myKey"];', "script" ); + var assignment = ast.body[1]; + var memberExpr = assignment.right; + + expect( memberExpr.type ).toBe( "MemberExpression" ); + expect( memberExpr.computed ).toBeTrue( "bracket notation should be computed" ); + expect( memberExpr.property.type ).toBe( "StringLiteral" ); + expect( memberExpr.property.value ).toBe( "myKey" ); + expect( memberExpr.property ).toHaveKey( "quoteChar", "bracket notation string key should have quoteChar" ); + expect( memberExpr.property.quoteChar ).toBe( '"', "Should preserve double quote in bracket notation" ); + }); + + it( "should preserve quoteChar for bracket notation string key with single quotes", function() { + // struct['key'] - the string key should have quoteChar + var ast = astFromString( "x = myStruct['myKey'];", "script" ); + var assignment = ast.body[1]; + var memberExpr = assignment.right; + + expect( memberExpr.type ).toBe( "MemberExpression" ); + expect( memberExpr.computed ).toBeTrue( "bracket notation should be computed" ); + expect( memberExpr.property.type ).toBe( "StringLiteral" ); + expect( memberExpr.property.value ).toBe( "myKey" ); + expect( memberExpr.property ).toHaveKey( "quoteChar", "bracket notation string key should have quoteChar" ); + expect( memberExpr.property.quoteChar ).toBe( "'", "Should preserve single quote in bracket notation" ); + }); + + it( "should preserve quoteChar for bracket notation with hyphenated key", function() { + // struct["hyphen-key"] - common pattern, must have quoteChar to round-trip + var ast = astFromString( 'x = headers["X-CSRF-Token"];', "script" ); + var assignment = ast.body[1]; + var memberExpr = assignment.right; + + expect( memberExpr.type ).toBe( "MemberExpression" ); + expect( memberExpr.computed ).toBeTrue( "bracket notation should be computed" ); + expect( memberExpr.property.type ).toBe( "StringLiteral" ); + expect( memberExpr.property.value ).toBe( "X-CSRF-Token" ); + expect( memberExpr.property ).toHaveKey( "quoteChar", "hyphenated bracket key must have quoteChar for round-trip" ); + expect( memberExpr.property.quoteChar ).toBe( '"' ); + }); + + it( "should preserve quoteChar for nested bracket notation", function() { + // struct["a"]["b"] - both keys should have quoteChar + var ast = astFromString( 'x = data["level1"]["level2"];', "script" ); + var assignment = ast.body[1]; + var outerMember = assignment.right; + + expect( outerMember.type ).toBe( "MemberExpression" ); + expect( outerMember.property.type ).toBe( "StringLiteral" ); + expect( outerMember.property.value ).toBe( "level2" ); + expect( outerMember.property ).toHaveKey( "quoteChar", "outer bracket key should have quoteChar" ); + + var innerMember = outerMember.object; + expect( innerMember.type ).toBe( "MemberExpression" ); + expect( innerMember.property.type ).toBe( "StringLiteral" ); + expect( innerMember.property.value ).toBe( "level1" ); + expect( innerMember.property ).toHaveKey( "quoteChar", "inner bracket key should have quoteChar" ); + }); + + }); + + describe( "Break/Continue label quoteChar", function() { + + // SKIP: break label uses CastExpression, continue uses StringLiteral - Lucee AST inconsistency + xit( "should use consistent AST type for break and continue labels", function() { + // Both break and continue should use same AST type for unquoted labels + var breakAst = astFromString( "outer: for( i = 1; i <= 5; i++ ) { break outer; }", "script" ); + var continueAst = astFromString( "outer: for( i = 1; i <= 5; i++ ) { continue outer; }", "script" ); + + var breakStmt = breakAst.body[1].body.body[1]; + var continueStmt = continueAst.body[1].body.body[1]; + + var breakLabel = breakStmt.attributes.filter( function( a ) { return a.name == "label"; } )[1]; + var continueLabel = continueStmt.attributes.filter( function( a ) { return a.name == "label"; } )[1]; + + // Both should have same AST type for the label value + expect( breakLabel.value.type ).toBe( continueLabel.value.type, + "break and continue should use consistent AST type for unquoted labels (break=#breakLabel.value.type#, continue=#continueLabel.value.type#)" ); + }); + + it( "should NOT have quoteChar for unquoted continue label", function() { + // continue outer; - unquoted label should NOT have quoteChar + var ast = astFromString( "outer: for( i = 1; i <= 5; i++ ) { continue outer; }", "script" ); + var forStmt = ast.body[1]; + var continueStmt = forStmt.body.body[1]; + + expect( continueStmt.type ).toBe( "CFMLTag" ); + expect( continueStmt.name ).toBe( "continue" ); + + var labelAttr = continueStmt.attributes.filter( function( a ) { return a.name == "label"; } )[1]; + expect( labelAttr ).toBeStruct( "continue should have label attribute" ); + expect( labelAttr.value.type ).toBe( "StringLiteral" ); + expect( labelAttr.value.value ).toBe( "outer" ); + // Unquoted label should NOT have quoteChar + expect( labelAttr.value ).notToHaveKey( "quoteChar", "unquoted continue label should not have quoteChar" ); + }); + + }); + + describe( "Param name attribute quoteChar", function() { + + xit( "should have quoteChar for param name attribute (shorthand syntax)", function() { + // param url = {}; - shorthand syntax, name should have quoteChar + var ast = astFromString( 'param url = {};', "script" ); + var paramTag = ast.body[1]; + + expect( paramTag.type ).toBe( "CFMLTag" ); + expect( paramTag.name ).toBe( "param" ); + + var nameAttr = paramTag.attributes.filter( function( a ) { return a.name == "name"; } )[1]; + expect( nameAttr ).toBeStruct( "param should have name attribute" ); + expect( nameAttr.value.type ).toBe( "StringLiteral" ); + expect( nameAttr.value.value ).toBe( "url" ); + // Param name needs quoteChar for round-trip - otherwise re-parses as variable reference + expect( nameAttr.value ).toHaveKey( "quoteChar", "param name attribute needs quoteChar for round-trip" ); + }); + + xit( "should have quoteChar for param name attribute (explicit name= syntax)", function() { + // param name="myVar" default=""; - explicit syntax + var ast = astFromString( 'param name="myVar" default="";', "script" ); + var paramTag = ast.body[1]; + + expect( paramTag.type ).toBe( "CFMLTag" ); + expect( paramTag.name ).toBe( "param" ); + + var nameAttr = paramTag.attributes.filter( function( a ) { return a.name == "name"; } )[1]; + expect( nameAttr ).toBeStruct( "param should have name attribute" ); + expect( nameAttr.value.type ).toBe( "StringLiteral" ); + expect( nameAttr.value.value ).toBe( "myVar" ); + expect( nameAttr.value ).toHaveKey( "quoteChar", "explicit param name attribute should have quoteChar" ); + }); + }); } diff --git a/test/tickets/LDEV6015/unquotedNumericAttr.cfc b/test/tickets/LDEV6015/unquotedNumericAttr.cfc new file mode 100644 index 00000000000..382f0071f74 --- /dev/null +++ b/test/tickets/LDEV6015/unquotedNumericAttr.cfc @@ -0,0 +1,3 @@ +component { + cfimage( action="resize", width=100 ); +} From 8fccc3baf7dfd661129944436bbd48ec07a564a0 Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Mon, 22 Dec 2025 17:55:56 +0100 Subject: [PATCH 62/71] LDEV-6032 parse named attributes correctly for single-type script tags --- .../script/AbstrCFMLScriptTransformer.java | 52 ++++++++++++++++++- test/tickets/LDEV6032.cfc | 15 +++++- test/tickets/LDEV6032/script-exit-method.cfm | 3 ++ 3 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 test/tickets/LDEV6032/script-exit-method.cfm diff --git a/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java b/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java index 143d02d570f..82975b251a3 100755 --- a/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java +++ b/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java @@ -2149,6 +2149,7 @@ private final Statement __singleAttrStatement(Body parent, Data data, TagLibTag String attrName = null; Expression attrValue = null; short attrType = ATTR_TYPE_NONE; + boolean handledAsNamedAttrs = false; if (attr != null) { attrType = attr.getScriptSupport(); char c = data.srcCode.getCurrent(); @@ -2163,6 +2164,15 @@ private final Statement __singleAttrStatement(Body parent, Data data, TagLibTag data.srcCode.setPos(p); } } + // Check if this looks like a named attribute (identifier="literal") rather than positional + else if (looksLikeNamedAttribute(data, tlt)) { + // Parse as named attributes instead + Attribute[] attrs = attributes(tag, tlt, data, SEMI_BLOCK, data.factory.EMPTY(), tlt.getScript().getRtexpr() ? Boolean.TRUE : Boolean.FALSE, null, false, ',', false); + for (Attribute a : attrs) { + tag.addAttribute(a); + } + handledAsNamedAttrs = true; + } else attrValue = attributeValue(data, tlt.getScript().getRtexpr()); if (attrValue != null && isOperator(c)) { @@ -2177,7 +2187,7 @@ private final Statement __singleAttrStatement(Body parent, Data data, TagLibTag TagLibTagAttr tlta = tlt.getAttribute(attr.getName(), true); tag.addAttribute(new Attribute(false, attrName, data.factory.toExpression(attrValue, tlta.getType()), tlta.getType())); } - else if (ATTR_TYPE_REQUIRED == attrType) { + else if (ATTR_TYPE_REQUIRED == attrType && !handledAsNamedAttrs) { data.srcCode.setPos(pos); return null; } @@ -2208,6 +2218,46 @@ private boolean isOperator(char c) { return c == '=' || c == '+' || c == '-'; } + /** + * Check if current position looks like a named attribute (identifier="literal") for a single-attr tag. + * This prevents "exit method="exitTag"" from being parsed as an assignment expression. + * Only triggers for literal values (quoted strings), NOT for expressions like "include template=var". + */ + private boolean looksLikeNamedAttribute(Data data, TagLibTag tlt) { + int pos = data.srcCode.getPos(); + try { + // Try to read an identifier + String id = CFMLTransformer.identifier(data.srcCode, false, true); + if (StringUtil.isEmpty(id)) return false; + + // Skip whitespace + data.srcCode.removeSpace(); + + // Check if followed by = + if (!data.srcCode.isCurrent('=')) return false; + + // Check if this identifier is a known attribute for this tag + if (tlt == null || tlt.getAttribute(id.toLowerCase(), true) == null) { + return false; + } + + // Move past the = + data.srcCode.next(); + data.srcCode.removeSpace(); + + // Only treat as named attribute if the value is a quoted string literal + // This distinguishes "exit method="exitTag"" from "include template=var" + char c = data.srcCode.getCurrent(); + return c == '"' || c == '\''; + } + catch (TemplateException e) { + return false; + } + finally { + data.srcCode.setPos(pos); + } + } + /* * protected Statement __singleAttrStatement(Body parent, Data data, String tagName,String * attrName,int attrType, boolean allowExpression, boolean allowTwiceAttr) throws TemplateException diff --git a/test/tickets/LDEV6032.cfc b/test/tickets/LDEV6032.cfc index a013e591cb1..d03eae9601d 100644 --- a/test/tickets/LDEV6032.cfc +++ b/test/tickets/LDEV6032.cfc @@ -1,4 +1,4 @@ -component extends="org.lucee.cfml.test.LuceeTestCase" { +component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { function run( testResults, testBox ) { describe( "LDEV-6032: Boolean attribute values in AST", function() { @@ -66,6 +66,19 @@ component extends="org.lucee.cfml.test.LuceeTestCase" { expect( abortAttr.value.value ).toBeTrue(); }); + // Script mode string attribute test - exit method="exitTag" + it( title="should parse string attribute as StringLiteral in script mode", body=function() { + var ast = getAst( "script-exit-method.cfm" ); + var exitTag = findScriptTag( ast, "exit" ); + + expect( exitTag ).notToBeNull(); + var attr = findAttribute( exitTag, "method" ); + expect( attr ).notToBeNull(); + // Should be StringLiteral, not CastExpression(AssignmentExpression) + expect( attr.value.type ).toBe( "StringLiteral" ); + expect( attr.value.value ).toBe( "exitTag" ); + } ); + }); } diff --git a/test/tickets/LDEV6032/script-exit-method.cfm b/test/tickets/LDEV6032/script-exit-method.cfm new file mode 100644 index 00000000000..13a13167414 --- /dev/null +++ b/test/tickets/LDEV6032/script-exit-method.cfm @@ -0,0 +1,3 @@ + +exit method="exitTag"; + From b9665c75d3baf3c3ca7456589add308e01d5c071 Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Mon, 22 Dec 2025 18:16:58 +0100 Subject: [PATCH 63/71] LDEV-6032 naked boolean attributes in script mode should be BooleanLiteral(true) --- .../cfml/script/AbstrCFMLScriptTransformer.java | 3 ++- test/tickets/LDEV6032.cfc | 17 +++++++++++++++++ .../tickets/LDEV6032/script-naked-singleton.cfc | 1 + 3 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 test/tickets/LDEV6032/script-naked-singleton.cfc diff --git a/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java b/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java index 82975b251a3..1309f0ce9f2 100755 --- a/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java +++ b/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java @@ -2674,7 +2674,8 @@ private final Attribute attribute(TagLibTag tlt, Data data, ArrayList ar } else { - value = defaultValue; + // Naked attribute (no value) should be BooleanLiteral(true), not empty string + value = data.factory.TRUE(); } comments(data); diff --git a/test/tickets/LDEV6032.cfc b/test/tickets/LDEV6032.cfc index d03eae9601d..db0af3f4b2d 100644 --- a/test/tickets/LDEV6032.cfc +++ b/test/tickets/LDEV6032.cfc @@ -66,6 +66,23 @@ component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { expect( abortAttr.value.value ).toBeTrue(); }); + // Quirk #65: Naked boolean attributes in script mode + // BUG: Naked attributes like "singleton" are parsed as StringLiteral("") not BooleanLiteral(true) + it( title="should parse naked boolean attribute as BooleanLiteral in script mode", body=function() { + var ast = astFromPath( getDirectoryFromPath( getCurrentTemplatePath() ) & "LDEV6032/script-naked-singleton.cfc" ); + + // Find the component tag + var componentTag = ast.body[ 1 ]; + expect( componentTag.type ).toBe( "CFMLTag" ); + expect( componentTag.name ).toBe( "component" ); + + var singletonAttr = findAttribute( componentTag, "singleton" ); + expect( singletonAttr ).notToBeNull( "singleton attribute should exist" ); + // BUG: Currently StringLiteral with value "", should be BooleanLiteral with value true + expect( singletonAttr.value.type ).toBe( "BooleanLiteral" ); + expect( singletonAttr.value.value ).toBeTrue(); + } ); + // Script mode string attribute test - exit method="exitTag" it( title="should parse string attribute as StringLiteral in script mode", body=function() { var ast = getAst( "script-exit-method.cfm" ); diff --git a/test/tickets/LDEV6032/script-naked-singleton.cfc b/test/tickets/LDEV6032/script-naked-singleton.cfc new file mode 100644 index 00000000000..1061c630acf --- /dev/null +++ b/test/tickets/LDEV6032/script-naked-singleton.cfc @@ -0,0 +1 @@ +component singleton {} From 652d5a0906f3e6827769aa13d4fefe20d9740a60 Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Mon, 22 Dec 2025 19:50:30 +0100 Subject: [PATCH 64/71] LDEV-6032 preserve NULL default for positional attributes in property/param Naked attributes should only be TRUE for boolean-like contexts (e.g. component singleton), not for positional arguments where NULL indicates type/name positions (e.g. property string name). --- .../cfml/script/AbstrCFMLScriptTransformer.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java b/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java index 1309f0ce9f2..7085abfccbf 100755 --- a/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java +++ b/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java @@ -2674,8 +2674,14 @@ private final Attribute attribute(TagLibTag tlt, Data data, ArrayList ar } else { - // Naked attribute (no value) should be BooleanLiteral(true), not empty string - value = data.factory.TRUE(); + // Naked attribute (no value) - use TRUE for boolean-like contexts, or defaultValue for positional contexts + // When defaultValue is NULL, it indicates positional arguments (like property type/name) that shouldn't be boolean + if (defaultValue instanceof Null) { + value = defaultValue; + } + else { + value = data.factory.TRUE(); + } } comments(data); From f680b64c04cfd6f94c218996d48d96b4b4c818db Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Mon, 22 Dec 2025 20:00:29 +0100 Subject: [PATCH 65/71] LDEV-6038 prevent ghost nodes after trailing comments in tag mode Check for end of source after stripping comments to avoid calling literal() which would create an empty StringLiteral node. --- .../transformer/cfml/tag/CFMLTransformer.java | 2 + test/tickets/LDEV6038.cfc | 48 +++++++++++++++++++ test/tickets/LDEV6038/test.cfm | 1 + 3 files changed, 51 insertions(+) create mode 100644 test/tickets/LDEV6038.cfc create mode 100644 test/tickets/LDEV6038/test.cfm diff --git a/core/src/main/java/lucee/transformer/cfml/tag/CFMLTransformer.java b/core/src/main/java/lucee/transformer/cfml/tag/CFMLTransformer.java index f57628a9f89..16196b6d35a 100755 --- a/core/src/main/java/lucee/transformer/cfml/tag/CFMLTransformer.java +++ b/core/src/main/java/lucee/transformer/cfml/tag/CFMLTransformer.java @@ -440,6 +440,8 @@ public void body(Data data, Body body) throws TemplateException { // Comment comment(data.srcCode, false); + // Check if we've reached the end after stripping comments (LDEV-6038) + if (!data.srcCode.isValidIndex()) break; // Tag // is Tag Beginning if (data.srcCode.isCurrent('<')) { diff --git a/test/tickets/LDEV6038.cfc b/test/tickets/LDEV6038.cfc new file mode 100644 index 00000000000..0f2b503ceaf --- /dev/null +++ b/test/tickets/LDEV6038.cfc @@ -0,0 +1,48 @@ +component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { + + function run( testResults, testBox ) { + describe( "LDEV-6038: Comments should not leave ghost nodes in AST", function() { + + it( "should not create empty StringLiteral placeholder for stripped comments", function() { + var ast = astFromPath( getDirectoryFromPath( getCurrentTemplatePath() ) & "LDEV6038/test.cfm" ); + + // Source: + // Should have 1 body element (the cfset tag) + // Bug: Has 2 body elements (cfset + empty StringLiteral ghost node) + + expect( ast.body ).toBeArray(); + expect( ast.body.len() ).toBe( 1, "AST should have exactly 1 body element, not a ghost node for the comment" ); + expect( ast.body[1].type ).toBe( "CFMLTag" ); + expect( ast.body[1].name ).toBe( "set" ); + }); + + it( "should not create ghost nodes for comments between tags", function() { + var code = ''; + var ast = astFromString( code ); + + // Should have 2 body elements (two cfset tags) + // Bug: Has 3 body elements (cfset + empty ghost + cfset) + + expect( ast.body ).toBeArray(); + expect( ast.body.len() ).toBe( 2, "AST should have exactly 2 body elements" ); + expect( ast.body[1].name ).toBe( "set" ); + expect( ast.body[2].name ).toBe( "set" ); + }); + + it( "should not create ghost nodes for trailing comments", function() { + var code = 'content'; + var ast = astFromString( code ); + + // Should have 1 body element (the cfmodule tag) + // Bug: Has 2 body elements (cfmodule + empty StringLiteral ghost) + + expect( ast.body ).toBeArray(); + expect( ast.body.len() ).toBe( 1, "AST should have exactly 1 body element for cfmodule" ); + expect( ast.body[1].type ).toBe( "CFMLTag" ); + expect( ast.body[1].name ).toBe( "module" ); + }); + + }); + } + +} diff --git a/test/tickets/LDEV6038/test.cfm b/test/tickets/LDEV6038/test.cfm new file mode 100644 index 00000000000..cc6c59935be --- /dev/null +++ b/test/tickets/LDEV6038/test.cfm @@ -0,0 +1 @@ + \ No newline at end of file From d3ab2102ac483a2948fc442dbf8b5fa837118b88 Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Mon, 22 Dec 2025 20:32:18 +0100 Subject: [PATCH 66/71] LDEV-6032 support parenthesized named attributes in single-type script tags Handle syntax like throw (message="test") by detecting and skipping optional parentheses around named attributes. --- .../script/AbstrCFMLScriptTransformer.java | 19 ++++++++++++++++++- test/tickets/LDEV6032.cfc | 18 ++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java b/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java index 7085abfccbf..38c4318dab9 100755 --- a/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java +++ b/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java @@ -2167,10 +2167,19 @@ private final Statement __singleAttrStatement(Body parent, Data data, TagLibTag // Check if this looks like a named attribute (identifier="literal") rather than positional else if (looksLikeNamedAttribute(data, tlt)) { // Parse as named attributes instead - Attribute[] attrs = attributes(tag, tlt, data, SEMI_BLOCK, data.factory.EMPTY(), tlt.getScript().getRtexpr() ? Boolean.TRUE : Boolean.FALSE, null, false, ',', false); + // Handle optional parentheses around attributes: throw (message="test") + boolean hasParen = data.srcCode.forwardIfCurrent('('); + if (hasParen) data.srcCode.removeSpace(); + Attribute[] attrs = attributes(tag, tlt, data, hasParen ? BRACKED : SEMI_BLOCK, data.factory.EMPTY(), tlt.getScript().getRtexpr() ? Boolean.TRUE : Boolean.FALSE, null, false, ',', false); for (Attribute a : attrs) { tag.addAttribute(a); } + if (hasParen) { + data.srcCode.removeSpace(); + if (!data.srcCode.forwardIfCurrent(')')) { + throw new TemplateException(data.srcCode, "missing closing parenthesis for tag attributes"); + } + } handledAsNamedAttrs = true; } else attrValue = attributeValue(data, tlt.getScript().getRtexpr()); @@ -2222,10 +2231,18 @@ private boolean isOperator(char c) { * Check if current position looks like a named attribute (identifier="literal") for a single-attr tag. * This prevents "exit method="exitTag"" from being parsed as an assignment expression. * Only triggers for literal values (quoted strings), NOT for expressions like "include template=var". + * Also handles parenthesized attributes like "throw (message="test")". */ private boolean looksLikeNamedAttribute(Data data, TagLibTag tlt) { int pos = data.srcCode.getPos(); try { + // Skip opening paren if present (for syntax like "throw (message="test")") + boolean hasParen = data.srcCode.isCurrent('('); + if (hasParen) { + data.srcCode.next(); + data.srcCode.removeSpace(); + } + // Try to read an identifier String id = CFMLTransformer.identifier(data.srcCode, false, true); if (StringUtil.isEmpty(id)) return false; diff --git a/test/tickets/LDEV6032.cfc b/test/tickets/LDEV6032.cfc index db0af3f4b2d..898971118f2 100644 --- a/test/tickets/LDEV6032.cfc +++ b/test/tickets/LDEV6032.cfc @@ -96,6 +96,24 @@ component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { expect( attr.value.value ).toBe( "exitTag" ); } ); + // Parenthesized script tag attributes - throw (message="test") + // Same bug as above but with parentheses around attributes + it( title="should parse parenthesized string attribute as StringLiteral", body=function() { + var ast = astFromString( 'throw (message="test");', "script" ); + + expect( ast.body ).toBeArray(); + expect( ast.body.len() ).toBe( 1 ); + var throwTag = ast.body[ 1 ]; + expect( throwTag.type ).toBe( "CFMLTag" ); + expect( throwTag.name ).toBe( "throw" ); + + var attr = findAttribute( throwTag, "message" ); + expect( attr ).notToBeNull(); + // BUG: Currently AssignmentExpression, should be StringLiteral + expect( attr.value.type ).toBe( "StringLiteral", "Attribute value should be StringLiteral, not AssignmentExpression" ); + expect( attr.value.value ).toBe( "test" ); + } ); + }); } From aa4bce7ad9b1e5698485fe7d0d63528856e2a782 Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Mon, 22 Dec 2025 22:22:17 +0100 Subject: [PATCH 67/71] LDEV-6015 preserve position for unquoted function attribute values --- .../bytecode/statement/udf/Function.java | 3 +- test/tickets/LDEV6015.cfc | 95 +++++++++++++++++++ 2 files changed, 97 insertions(+), 1 deletion(-) diff --git a/core/src/main/java/lucee/transformer/bytecode/statement/udf/Function.java b/core/src/main/java/lucee/transformer/bytecode/statement/udf/Function.java index 69227f375a8..78562bedbd0 100644 --- a/core/src/main/java/lucee/transformer/bytecode/statement/udf/Function.java +++ b/core/src/main/java/lucee/transformer/bytecode/statement/udf/Function.java @@ -623,9 +623,10 @@ private final LitString toLitString(BytecodeContext bc, String name, Expression ExprString es = value.getFactory().toExprString(value); if (!(es instanceof LitString)) { // Handle unquoted identifiers like access=remote - convert Variable to LitString + // Preserve position from original expression for AST round-tripping (LDEV-6015) String str = ASMUtil.toString(bc, value, null); if (str != null) { - return value.getFactory().createLitString(str); + return value.getFactory().createLitString(str, value.getStart(), value.getEnd()); } throw new TransformerException(bc, "Value of attribute [" + name + "] must have a literal/constant value", getStart()); } diff --git a/test/tickets/LDEV6015.cfc b/test/tickets/LDEV6015.cfc index b7bc8638693..0107a2f8a55 100644 --- a/test/tickets/LDEV6015.cfc +++ b/test/tickets/LDEV6015.cfc @@ -213,6 +213,101 @@ component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { }); + describe( "Function returntype attribute quoteChar", function() { + + it( "should have quoteChar for quoted returntype attribute", function() { + // function test() returntype="boolean" {} - quoted attribute should have quoteChar + var ast = astFromString( 'function test() returntype="boolean" {}', "script" ); + var func = ast.body[1]; + + expect( func.type ).toBe( "FunctionDeclaration" ); + expect( func.returnType.type ).toBe( "StringLiteral" ); + expect( func.returnType.value ).toBe( "boolean" ); + expect( func.returnType ).toHaveKey( "quoteChar", "quoted returntype attribute should have quoteChar" ); + expect( func.returnType.quoteChar ).toBe( '"' ); + }); + + it( "should NOT have quoteChar for inline returntype keyword", function() { + // boolean function test() {} - inline keyword is NOT an attribute + var ast = astFromString( 'boolean function test() {}', "script" ); + var func = ast.body[1]; + + expect( func.type ).toBe( "FunctionDeclaration" ); + expect( func.returnType.type ).toBe( "StringLiteral" ); + expect( func.returnType.value ).toBe( "boolean" ); + // Inline keyword should NOT have quoteChar - it's not quoted in source + expect( func.returnType ).notToHaveKey( "quoteChar", "inline returntype keyword should not have quoteChar" ); + }); + + it( "should have position for unquoted returntype attribute (distinguishes from keyword)", function() { + // function test() returntype=boolean {} - attribute syntax has position data + // boolean function test() {} - keyword syntax has NO position data + // Position presence distinguishes attribute from keyword, not quoteChar + var ast = astFromString( 'function test() returntype=boolean {}', "script" ); + var func = ast.body[1]; + + expect( func.type ).toBe( "FunctionDeclaration" ); + // Attribute syntax should have position data (distinguishes from keyword) + expect( func.returnType ).toHaveKey( "start", "unquoted returntype attribute should have position (distinguishes from keyword)" ); + }); + + }); + + describe( "Param shorthand type attribute", function() { + + it( "should have StringLiteral WITHOUT position for shorthand type", function() { + // param struct e; - shorthand type produces synthetic StringLiteral (no position) + var ast = astFromString( 'param struct e;', "script" ); + var paramTag = ast.body[1]; + + expect( paramTag.type ).toBe( "CFMLTag" ); + expect( paramTag.name ).toBe( "param" ); + + var typeAttr = paramTag.attributes.filter( function( a ) { return a.name == "type"; } )[1]; + expect( typeAttr ).toBeStruct( "param should have type attribute" ); + expect( typeAttr.value.type ).toBe( "StringLiteral" ); + expect( typeAttr.value.value ).toBe( "struct" ); + // Shorthand type is synthetic - no position info + expect( typeAttr.value ).notToHaveKey( "start", "shorthand type should NOT have position (synthetic node)" ); + }); + + it( "should have CastExpression for unquoted explicit type=", function() { + // param e type=struct; - unquoted explicit type parses as CastExpression + var ast = astFromString( 'param e type=struct;', "script" ); + var paramTag = ast.body[1]; + + expect( paramTag.type ).toBe( "CFMLTag" ); + expect( paramTag.name ).toBe( "param" ); + + var typeAttr = paramTag.attributes.filter( function( a ) { return a.name == "type"; } )[1]; + expect( typeAttr ).toBeStruct( "param should have type attribute" ); + // Unquoted type= parses as variable reference (CastExpression wrapping Identifier) + expect( typeAttr.value.type ).toBe( "CastExpression" ); + expect( typeAttr.value.argument.type ).toBe( "Identifier" ); + expect( typeAttr.value.argument.name ).toBe( "STRUCT" ); + }); + + it( "should have StringLiteral WITH quoteChar for quoted explicit type=", function() { + // param e type="struct"; - quoted explicit type parses as StringLiteral + var ast = astFromString( 'param e type="struct";', "script" ); + var paramTag = ast.body[1]; + + expect( paramTag.type ).toBe( "CFMLTag" ); + expect( paramTag.name ).toBe( "param" ); + + var typeAttr = paramTag.attributes.filter( function( a ) { return a.name == "type"; } )[1]; + expect( typeAttr ).toBeStruct( "param should have type attribute" ); + expect( typeAttr.value.type ).toBe( "StringLiteral" ); + expect( typeAttr.value.value ).toBe( "struct" ); + // Quoted type= has quoteChar + expect( typeAttr.value ).toHaveKey( "quoteChar", "quoted type attribute should have quoteChar" ); + expect( typeAttr.value.quoteChar ).toBe( '"' ); + // And has position (real source node) + expect( typeAttr.value ).toHaveKey( "start", "quoted type should have position" ); + }); + + }); + describe( "Param name attribute quoteChar", function() { xit( "should have quoteChar for param name attribute (shorthand syntax)", function() { From de8df7005a6b0185fb544904f21766bfbf7a6084 Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Mon, 22 Dec 2025 22:50:29 +0100 Subject: [PATCH 68/71] LDEV-6011 add separator field to Attribute for AST round-tripping --- .../bytecode/statement/tag/TagBase.java | 1 + .../script/AbstrCFMLScriptTransformer.java | 16 ++++++++++--- .../transformer/statement/tag/Attribute.java | 17 ++++++++++++++ test/tickets/LDEV6011.cfc | 23 +++++++++++++++++++ 4 files changed, 54 insertions(+), 3 deletions(-) diff --git a/core/src/main/java/lucee/transformer/bytecode/statement/tag/TagBase.java b/core/src/main/java/lucee/transformer/bytecode/statement/tag/TagBase.java index 71ac6d82d6a..59fb7a02433 100755 --- a/core/src/main/java/lucee/transformer/bytecode/statement/tag/TagBase.java +++ b/core/src/main/java/lucee/transformer/bytecode/statement/tag/TagBase.java @@ -292,6 +292,7 @@ public void dump(Struct sct) { arrAttrs.appendEL(sctAttr); sctAttr.setEL(KeyConstants._name, attr.getName()); sctAttr.setEL(KeyConstants._type, "Attribute"); + sctAttr.setEL(KeyConstants._separator, String.valueOf(attr.getSeparator())); Struct val = new StructImpl(Struct.TYPE_LINKED); attr.getValue().dump(val); diff --git a/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java b/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java index 38c4318dab9..c9f062d0ac2 100755 --- a/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java +++ b/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java @@ -2683,8 +2683,18 @@ private final Attribute attribute(TagLibTag tlt, Data data, ArrayList ar comments(data); - // value - boolean hasValue = data.srcCode.forwardIfCurrent('=') || (allowColonSeparator && data.srcCode.forwardIfCurrent(':')); + // value - track which separator was used + char separator = Attribute.SEPARATOR_EQUALS; + boolean hasValue = false; + if (data.srcCode.forwardIfCurrent('=')) { + hasValue = true; + separator = Attribute.SEPARATOR_EQUALS; + } + else if (allowColonSeparator && data.srcCode.forwardIfCurrent(':')) { + hasValue = true; + separator = Attribute.SEPARATOR_COLON; + } + if (hasValue) { comments(data); value = attributeValue(data, allowExpression); @@ -2708,7 +2718,7 @@ private final Attribute attribute(TagLibTag tlt, Data data, ArrayList ar tlta = tlt.getAttribute(nameLC, true); if (tlta != null && tlta.getName() != null) nameLC = tlta.getName(); } - return new Attribute(dynamic.toBooleanValue(), name, tlta != null ? data.factory.toExpression(value, tlta.getType()) : value, sbType.toString(), !hasValue); + return new Attribute(dynamic.toBooleanValue(), name, tlta != null ? data.factory.toExpression(value, tlta.getType()) : value, sbType.toString(), !hasValue, separator); } private final String attributeName(SourceCode cfml, ArrayList args, TagLibTag tag, RefBoolean dynamic, StringBuilder sbType, boolean allowTwiceAttr, boolean allowColon) diff --git a/core/src/main/java/lucee/transformer/statement/tag/Attribute.java b/core/src/main/java/lucee/transformer/statement/tag/Attribute.java index b1b5d045466..17f701add26 100755 --- a/core/src/main/java/lucee/transformer/statement/tag/Attribute.java +++ b/core/src/main/java/lucee/transformer/statement/tag/Attribute.java @@ -22,6 +22,9 @@ public final class Attribute { + public static final char SEPARATOR_EQUALS = '='; + public static final char SEPARATOR_COLON = ':'; + final String nameOC; final String nameLC; final Expression value; @@ -30,6 +33,7 @@ public final class Attribute { private boolean defaultAttribute; private String setterName; private final boolean isDefaultValue; + private char separator = SEPARATOR_EQUALS; // default to '=' for backwards compatibility public Attribute(boolean dynamicType, String name, Expression value, String type) { this(dynamicType, name, value, type, false); @@ -44,6 +48,19 @@ public Attribute(boolean dynamicType, String name, Expression value, String type this.isDefaultValue = isDefaultValue; } + public Attribute(boolean dynamicType, String name, Expression value, String type, boolean isDefaultValue, char separator) { + this(dynamicType, name, value, type, isDefaultValue); + this.separator = separator; + } + + public char getSeparator() { + return separator; + } + + public void setSeparator(char separator) { + this.separator = separator; + } + public boolean isDefaultValue() { return isDefaultValue; } diff --git a/test/tickets/LDEV6011.cfc b/test/tickets/LDEV6011.cfc index 78069ddc5aa..77973e91a8a 100644 --- a/test/tickets/LDEV6011.cfc +++ b/test/tickets/LDEV6011.cfc @@ -318,6 +318,29 @@ component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { expect( right.property.name ).toBe( "CONSTANT" ); }); + it( "should include separator field in CFMLTag Attribute nodes", function() { + // CFMLTag uses Attribute nodes (not NamedArgument), which should also have separator + // Note: throw (type: "test") with colon is parsed as CallExpression, not CFMLTag + // Only throw (type="test") with equals is parsed as CFMLTag + var equalsCode = 'throw ( type="test" );'; + + var equalsAst = astFromString( equalsCode, "script" ); + + // Should be CFMLTag with Attribute children + expect( equalsAst.body[1].type ).toBe( "CFMLTag" ); + + var equalsAttr = equalsAst.body[1].attributes[1]; + expect( equalsAttr.type ).toBe( "Attribute" ); + + // Should have a separator field + expect( equalsAttr ).toHaveKey( "separator", + "Attribute should have 'separator' field like NamedArgument does" ); + + // Equals syntax should have separator="=" + expect( equalsAttr.separator ).toBe( "=", + "Equals syntax throw ( type='test' ) should have separator='=' but got '#equalsAttr.separator ?: 'null'#'" ); + }); + }); } From 2930fc0e6e6dfd35b8fc4554814c67655b93e06a Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Mon, 22 Dec 2025 23:40:55 +0100 Subject: [PATCH 69/71] LDEV-5990 arrow functions should not consume outer function docblock --- .../script/AbstrCFMLScriptTransformer.java | 7 +- test/tickets/LDEV5990.cfc | 76 +++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java b/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java index c9f062d0ac2..6167ebca6d0 100755 --- a/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java +++ b/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java @@ -1400,10 +1400,14 @@ protected final Function lambdaPart(Data data, String id, int access, int modifi try { if (data.srcCode.isCurrent('{')) { + // Save docComment - block-style arrow functions shouldn't consume outer docblock (LDEV-5990) + DocComment savedDocComment = data.docComment; Body prior = data.setParent(body); statement(data, body, CTX_FUNCTION); data.setParent(prior); + // Restore docComment - let the outer caller handle it + data.docComment = savedDocComment; } else { if (data.srcCode.forwardIfCurrent("return ")) { @@ -1417,7 +1421,8 @@ protected final Function lambdaPart(Data data, String id, int access, int modifi Expression expr = expression(data); Return rtn = new Return(expr, line, data.srcCode.getPosition()); body.addStatement(rtn); - data.docComment = null; + // Don't clear docComment here - arrow functions shouldn't consume outer docblock (LDEV-5990) + // The outer caller (closurePart for named functions) handles docComment appropriately data.context = prior; } diff --git a/test/tickets/LDEV5990.cfc b/test/tickets/LDEV5990.cfc index 51492cd8428..6d60d80631b 100644 --- a/test/tickets/LDEV5990.cfc +++ b/test/tickets/LDEV5990.cfc @@ -246,6 +246,82 @@ component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { expect( closure ).notToHaveKey( "annotations", "closure default value should NOT have annotations" ); }); + it( "should attach docblock to outer function, not arrow function default value", function() { + // Arrow function defaults also consume the docblock incorrectly + var code = '/** +* @data.hint Data description +*/ +function tree( required struct data, formatUDF=()=>"" ){}'; + var ast = astFromString( code, "script" ); + + var func = ast.body[ 1 ]; + expect( func.type ).toBe( "FunctionDeclaration" ); + + // The docblock should be on the OUTER function + expect( func ).toHaveKey( "docblock", "outer function should have docblock when arrow function is default" ); + expect( func.docblock ).toInclude( "@data.hint" ); + + // Param hint should be preserved + var param = func.params[ 1 ]; + expect( param.name.value ).toBe( "data" ); + expect( param ).toHaveKey( "hint", "param should have hint from docblock" ); + expect( param.hint.value ).toBe( "Data description" ); + + // The arrow function default value should NOT have the docblock + var param2 = func.params[ 2 ]; + expect( param2.name.value ).toBe( "formatUDF" ); + expect( param2 ).toHaveKey( "defaultValue", "param should have defaultValue" ); + var arrow = param2.defaultValue; + expect( arrow.type ).toBeWithCase( "LambdaDeclaration" ); + expect( arrow ).notToHaveKey( "docblock", "arrow function default value should NOT have docblock" ); + expect( arrow ).notToHaveKey( "annotations", "arrow function default value should NOT have annotations" ); + }); + + it( "should attach docblock to outer function with expanded arrow function default", function() { + // Expanded arrow function form () => { return ""; } also loses docblock + var code = '/** +* @data.hint Data description +*/ +function tree( required struct data, formatUDF=() => { return ""; } ){}'; + var ast = astFromString( code, "script" ); + + var func = ast.body[ 1 ]; + expect( func.type ).toBe( "FunctionDeclaration" ); + + // The docblock should be on the OUTER function even with expanded arrow syntax + expect( func ).toHaveKey( "docblock", "outer function should have docblock when expanded arrow function is default" ); + expect( func.docblock ).toInclude( "@data.hint" ); + + // Param hint should be preserved + var param = func.params[ 1 ]; + expect( param.name.value ).toBe( "data" ); + expect( param ).toHaveKey( "hint", "param should have hint from docblock" ); + expect( param.hint.value ).toBe( "Data description" ); + }); + + // SKIP: Feature enhancement - not part of original bug fix + xit( "should distinguish concise vs block arrow function syntax", function() { + // Concise form: () => expr + var conciseAst = astFromString( '() => ""', "script" ); + var conciseLambda = conciseAst.body[ 1 ]; + expect( conciseLambda.type ).toBe( "LambdaDeclaration" ); + + // Block form: () => { return expr; } + var blockAst = astFromString( '() => { return ""; }', "script" ); + var blockLambda = blockAst.body[ 1 ]; + expect( blockLambda.type ).toBe( "LambdaDeclaration" ); + + // AST should have a field to distinguish them (e.g., "expression" or "concise") + // Concise form should have expression=true or similar + expect( conciseLambda ).toHaveKey( "expression", + "LambdaDeclaration should have 'expression' field to distinguish () => expr from () => { return expr; }" ); + expect( conciseLambda.expression ).toBeTrue( "Concise arrow () => expr should have expression=true" ); + + // Block form should have expression=false + expect( blockLambda ).toHaveKey( "expression" ); + expect( blockLambda.expression ).toBeFalse( "Block arrow () => { } should have expression=false" ); + }); + }); describe( "LDEV-5990: Docblock metadata tags should be in AST", function() { From f6e7034d0037992bbfb15f7a8edf8f9ad3ec623c Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Tue, 23 Dec 2025 15:03:09 +0100 Subject: [PATCH 70/71] LDEV-6041 preserve source fidelity in AST for var keyword and identifier case - Add declaration property to AssignmentExpression/ForOfStatement when var keyword used - Add explicit flag to returnType/param types to distinguish implicit from explicit any - Preserve original BIF callee name casing via originalName field - Add typeExplicit tracking in Argument for function parameters --- .../lucee/runtime/type/util/KeyConstants.java | 2 + .../bytecode/expression/var/Assign.java | 5 + .../bytecode/expression/var/BIF.java | 12 + .../bytecode/expression/var/VariableRef.java | 7 + .../bytecode/statement/ForEach.java | 6 + .../bytecode/statement/udf/Function.java | 28 ++- .../expression/AbstrCFMLExprTransformer.java | 5 +- .../script/AbstrCFMLScriptTransformer.java | 12 +- .../lucee/transformer/statement/Argument.java | 15 ++ test/tickets/LDEV6041.cfc | 238 ++++++++++++++++++ 10 files changed, 322 insertions(+), 8 deletions(-) create mode 100644 test/tickets/LDEV6041.cfc diff --git a/core/src/main/java/lucee/runtime/type/util/KeyConstants.java b/core/src/main/java/lucee/runtime/type/util/KeyConstants.java index f07550871da..804b69ebe8d 100644 --- a/core/src/main/java/lucee/runtime/type/util/KeyConstants.java +++ b/core/src/main/java/lucee/runtime/type/util/KeyConstants.java @@ -393,6 +393,7 @@ private static Collection.Key init(String key) { public static final Key _dc_subject = init("dc_subject"); public static final Key _debug = init("debug"); public static final Key _debugging = init("debugging"); + public static final Key _declaration = init("declaration"); public static final Key _decorator = init("decorator"); public static final Key _default = init("default"); public static final Key _delete = init("delete"); @@ -757,6 +758,7 @@ private static Collection.Key init(String key) { public static final Key _statuscode = init("statuscode"); public static final Key _statustext = init("statustext"); public static final Key _extends = init("extends"); + public static final Key _explicit = init("explicit"); public static final Key _implements = init("implements"); public static final Key __toDateTime = init("_toDateTime"); public static final Key __toNumeric = init("_toNumeric"); diff --git a/core/src/main/java/lucee/transformer/bytecode/expression/var/Assign.java b/core/src/main/java/lucee/transformer/bytecode/expression/var/Assign.java index bd43b309adb..ead0c3826d2 100755 --- a/core/src/main/java/lucee/transformer/bytecode/expression/var/Assign.java +++ b/core/src/main/java/lucee/transformer/bytecode/expression/var/Assign.java @@ -345,6 +345,11 @@ public void dump(Struct sct) { sct.setEL(KeyConstants._type, "AssignmentExpression"); sct.setEL(KeyConstants._operator, "ASSIGN"); + // LDEV-6041: Preserve var keyword declaration + if (variable.getScope() == Scope.SCOPE_VAR) { + sct.setEL(KeyConstants._declaration, "var"); + } + Struct left = new StructImpl(Struct.TYPE_LINKED); sct.setEL(KeyConstants._left, left); variable.dump(left); diff --git a/core/src/main/java/lucee/transformer/bytecode/expression/var/BIF.java b/core/src/main/java/lucee/transformer/bytecode/expression/var/BIF.java index 0e1e87705fe..e480d024661 100755 --- a/core/src/main/java/lucee/transformer/bytecode/expression/var/BIF.java +++ b/core/src/main/java/lucee/transformer/bytecode/expression/var/BIF.java @@ -34,6 +34,7 @@ public final class BIF extends FunctionMember { private ClassDefinition cd; private String returnType = ANY; private FunctionLibFunction flf; + private ExprString originalName; // LDEV-6041: preserve original parsed name for AST private final Factory factory; @@ -110,8 +111,19 @@ public void setFlf(FunctionLibFunction flf) { this.flf = flf; } + /** + * LDEV-6041: Store original parsed name for AST round-tripping + */ + public void setOriginalName(ExprString name) { + this.originalName = name; + } + @Override public ExprString getName() { + // LDEV-6041: Return original name if available (preserves case for AST) + if (originalName != null) { + return originalName; + } return factory.createLitString(flf.getName()); } } \ No newline at end of file diff --git a/core/src/main/java/lucee/transformer/bytecode/expression/var/VariableRef.java b/core/src/main/java/lucee/transformer/bytecode/expression/var/VariableRef.java index 0b39ca6db00..01f8b3eecdd 100755 --- a/core/src/main/java/lucee/transformer/bytecode/expression/var/VariableRef.java +++ b/core/src/main/java/lucee/transformer/bytecode/expression/var/VariableRef.java @@ -80,4 +80,11 @@ public Type _writeOut(BytecodeContext bc, int mode) throws TransformerException public void dump(Struct sct) { variable.dump(sct); } + + /** + * Get the underlying Variable for scope inspection + */ + public Variable getVariable() { + return variable; + } } \ No newline at end of file diff --git a/core/src/main/java/lucee/transformer/bytecode/statement/ForEach.java b/core/src/main/java/lucee/transformer/bytecode/statement/ForEach.java index 67a38751306..3563f2dc70c 100755 --- a/core/src/main/java/lucee/transformer/bytecode/statement/ForEach.java +++ b/core/src/main/java/lucee/transformer/bytecode/statement/ForEach.java @@ -26,6 +26,7 @@ import lucee.runtime.type.Struct; import lucee.runtime.type.StructImpl; +import lucee.runtime.type.scope.Scope; import lucee.runtime.type.util.KeyConstants; import lucee.runtime.util.ForEachUtil; import lucee.transformer.Body; @@ -173,6 +174,11 @@ public void dump(Struct sct) { super.dump(sct); sct.setEL(KeyConstants._type, "ForOfStatement"); + // LDEV-6041: Preserve var keyword declaration + if (key.getVariable().getScope() == Scope.SCOPE_VAR) { + sct.setEL(KeyConstants._declaration, "var"); + } + // label if ( label != null ) { sct.setEL(KeyConstants._label, label); diff --git a/core/src/main/java/lucee/transformer/bytecode/statement/udf/Function.java b/core/src/main/java/lucee/transformer/bytecode/statement/udf/Function.java index 78562bedbd0..8af49c74692 100644 --- a/core/src/main/java/lucee/transformer/bytecode/statement/udf/Function.java +++ b/core/src/main/java/lucee/transformer/bytecode/statement/udf/Function.java @@ -136,6 +136,7 @@ public abstract class Function extends StatementBaseNoFinal implements Opcodes, ExprString name; ExprString returnType; + boolean returnTypeExplicit; // LDEV-6041: track if return type was explicitly specified ExprBoolean output; ExprBoolean bufferOutput; // ExprBoolean abstry=LitBoolean.FALSE; @@ -167,7 +168,9 @@ public Function(String name, int access, int modifier, String returnType, Body b this.name = body.getFactory().createLitString(name); this.access = access; this.modifier = modifier; - if (!StringUtil.isEmpty(returnType)) this.returnType = body.getFactory().createLitString(returnType); + // LDEV-6041: Track if return type was explicitly specified + this.returnTypeExplicit = !StringUtil.isEmpty(returnType); + if (this.returnTypeExplicit) this.returnType = body.getFactory().createLitString(returnType); else this.returnType = body.getFactory().createLitString("any"); this.body = body; body.setParent(this); @@ -518,6 +521,13 @@ public final void addArgument(Expression name, Expression type, Expression requi arguments.add(new Argument(name, type, required, defaultValue, passByReference, displayName, hint, meta)); } + /** + * LDEV-6041: Add an existing Argument directly (preserves typeExplicit flag) + */ + public final void addArgument(Argument arg) { + arguments.add(arg); + } + /** * @return the arguments */ @@ -691,6 +701,10 @@ public void dump(Struct sct, String type) { if (returnType != null) { Struct s = new StructImpl(Struct.TYPE_LINKED); returnType.dump(s); + // LDEV-6041: Add explicit flag to distinguish "function test()" from "any function test()" + if (returnTypeExplicit) { + s.setEL(KeyConstants._explicit, Boolean.TRUE); + } sct.setEL(KeyConstants._returnType, s); } // returnFormat @@ -819,8 +833,16 @@ else if (cachedWithin != null) { Struct param = new StructImpl(Struct.TYPE_LINKED); params.appendEL(param); - Expression expr = arg.getType(); - set(param, arg.getType(), KeyConstants._type); + // LDEV-6041: Add explicit flag to type if explicitly specified + Expression typeExpr = arg.getType(); + if (typeExpr != null) { + Struct typeStruct = new StructImpl(Struct.TYPE_LINKED); + typeExpr.dump(typeStruct); + if (arg.isTypeExplicit()) { + typeStruct.setEL(KeyConstants._explicit, Boolean.TRUE); + } + param.setEL(KeyConstants._type, typeStruct); + } set(param, arg.getName(), KeyConstants._name); set(param, arg.getRequired(), KeyConstants._required); set(param, arg.getDefaultValue(), KeyConstants._defaultValue); diff --git a/core/src/main/java/lucee/transformer/cfml/expression/AbstrCFMLExprTransformer.java b/core/src/main/java/lucee/transformer/cfml/expression/AbstrCFMLExprTransformer.java index ce98459ac83..5e73bff9a73 100755 --- a/core/src/main/java/lucee/transformer/cfml/expression/AbstrCFMLExprTransformer.java +++ b/core/src/main/java/lucee/transformer/cfml/expression/AbstrCFMLExprTransformer.java @@ -352,8 +352,9 @@ else if (expr instanceof NullConstant) { } // patch for test()(); only works at the end of an expression! + // LDEV-6039: Don't treat ( as function call if there's a newline before it comments(data); - while (data.srcCode.isCurrent('(')) { + while (data.srcCode.isCurrent('(') && !data.srcCode.hasNLBefore()) { comments(data); Call call = new Call(expr); getFunctionMemberAttrs(data, null, false, call, null); @@ -1857,6 +1858,8 @@ private FunctionMember getFunctionMember(Data data, final ExprString name, boole if (checkLibrary) { BIF bif = new BIF(data.factory, data.settings, flf, data); // TODO data.ep.add(flf, bif, data.srcCode); + // LDEV-6041: Store original name for AST round-tripping + bif.setOriginalName(name); bif.setArgType(flf.getArgType()); try { diff --git a/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java b/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java index 6167ebca6d0..4e0ce755176 100755 --- a/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java +++ b/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java @@ -980,10 +980,12 @@ private ArrayList _getScriptFunctionArguments(Data data, ArrayList args = getScriptFunctionArguments(data); for (Argument arg: args) { - func.addArgument(arg.getName(), arg.getType(), arg.getRequired(), arg.getDefaultValue(), arg.isPassByReference(), arg.getDisplayName(), arg.getHint(), - arg.getMetaData()); + // LDEV-6041: Use addArgument(Argument) to preserve typeExplicit flag + func.addArgument(arg); } // end ) comments(data); diff --git a/core/src/main/java/lucee/transformer/statement/Argument.java b/core/src/main/java/lucee/transformer/statement/Argument.java index 173013f3f66..f8adff9ece4 100755 --- a/core/src/main/java/lucee/transformer/statement/Argument.java +++ b/core/src/main/java/lucee/transformer/statement/Argument.java @@ -32,6 +32,7 @@ public final class Argument { private ExprString name; private ExprString type; + private boolean typeExplicit; // LDEV-6041: track if type was explicitly specified private ExprBoolean required; private Expression defaultValue; private ExprString displayName; @@ -124,6 +125,20 @@ public ExprString getType() { return type; } + /** + * LDEV-6041: Check if type was explicitly specified + */ + public boolean isTypeExplicit() { + return typeExplicit; + } + + /** + * LDEV-6041: Set whether type was explicitly specified + */ + public void setTypeExplicit(boolean explicit) { + this.typeExplicit = explicit; + } + public Map getMetaData() { return meta; } diff --git a/test/tickets/LDEV6041.cfc b/test/tickets/LDEV6041.cfc new file mode 100644 index 00000000000..50f5d113c46 --- /dev/null +++ b/test/tickets/LDEV6041.cfc @@ -0,0 +1,238 @@ +component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { + + function run( testResults, testBox ) { + + describe( "LDEV-6041: AST should include original source fidelity", function() { + + describe( "2a. Identifier case preservation", function() { + + it( "should have raw property on Identifier with original casing", function() { + var ast = astFromString( 'myVar = someFunc( argName=value );', "script" ); + var assignment = ast.body[1]; + + // Left side - myVar identifier + var leftId = assignment.left; + expect( leftId.type ).toBe( "Identifier" ); + expect( leftId.name ).toBe( "MYVAR", "name should be uppercased" ); + expect( leftId ).toHaveKey( "raw", "Identifier should have raw property with original casing" ); + expect( leftId.raw ).toBeWithCase( "myVar", "raw should preserve original case" ); + }); + + it( "should preserve case for UDF callee names", function() { + var ast = astFromString( 'someFunc();', "script" ); + var call = ast.body[1]; + + expect( call.type ).toBe( "CallExpression" ); + var callee = call.callee; + expect( callee.type ).toBe( "Identifier" ); + expect( callee.name ).toBe( "SOMEFUNC", "name should be uppercased" ); + expect( callee ).toHaveKey( "raw", "UDF callee should have raw property" ); + expect( callee.raw ).toBeWithCase( "someFunc", "raw should preserve original case" ); + }); + + it( "should preserve case for named argument names", function() { + var ast = astFromString( 'test( argName=1 );', "script" ); + var call = ast.body[1]; + var namedArg = call.arguments[1]; + + expect( namedArg.type ).toBe( "NamedArgument" ); + var nameId = namedArg.name; + expect( nameId.type ).toBe( "Identifier" ); + expect( nameId.name ).toBe( "ARGNAME", "name should be uppercased" ); + expect( nameId ).toHaveKey( "raw", "named argument identifier should have raw property" ); + expect( nameId.raw ).toBeWithCase( "argName", "raw should preserve original case" ); + }); + + it( "should preserve case for BIF callee names", function() { + var ast = astFromString( 'arrayAppend( arr, val );', "script" ); + var call = ast.body[1]; + + expect( call.type ).toBe( "CallExpression" ); + expect( call.isBuiltIn ).toBeTrue( "should be marked as BIF" ); + var callee = call.callee; + expect( callee.type ).toBe( "Identifier" ); + expect( callee ).toHaveKey( "raw", "BIF callee should have raw property" ); + expect( callee.raw ).toBeWithCase( "arrayAppend", "raw should preserve original case" ); + }); + + // TODO: Scope identifiers (request, session, etc) are resolved to scope integers during parsing + // and the original identifier casing is discarded. Would require parser changes to preserve. + xit( "should preserve case for scope prefixes", function() { + var ast = astFromString( 'request.myKey = 1;', "script" ); + var assignment = ast.body[1]; + var memberExpr = assignment.left; + + expect( memberExpr.type ).toBe( "MemberExpression" ); + var scopeId = memberExpr.object; + expect( scopeId.type ).toBe( "Identifier" ); + expect( scopeId.name ).toBe( "REQUEST", "scope name should be uppercased" ); + expect( scopeId ).toHaveKey( "raw", "scope identifier should have raw property" ); + expect( scopeId.raw ).toBeWithCase( "request", "raw should preserve original lowercase" ); + }); + + // TODO: Same as above - scope identifier casing not preserved + xit( "should preserve case for uppercase scope", function() { + var ast = astFromString( 'REQUEST.myKey = 1;', "script" ); + var assignment = ast.body[1]; + var memberExpr = assignment.left; + var scopeId = memberExpr.object; + + expect( scopeId.name ).toBe( "REQUEST" ); + expect( scopeId ).toHaveKey( "raw", "scope identifier should have raw property" ); + expect( scopeId.raw ).toBeWithCase( "REQUEST", "raw should preserve original uppercase" ); + }); + + }); + + describe( "2b. var keyword preservation", function() { + + it( "should have declaration property when var keyword used", function() { + var ast = astFromString( 'function test() { var x = 1; }', "script" ); + var func = ast.body[1]; + var assignment = func.body.body[1]; + + expect( assignment.type ).toBe( "AssignmentExpression" ); + expect( assignment ).toHaveKey( "declaration", "var declaration should have declaration property" ); + expect( assignment.declaration ).toBe( "var", "declaration should be 'var'" ); + }); + + it( "should NOT have declaration property for explicit local scope", function() { + var ast = astFromString( 'function test() { local.x = 1; }', "script" ); + var func = ast.body[1]; + var assignment = func.body.body[1]; + + expect( assignment.type ).toBe( "AssignmentExpression" ); + expect( assignment ).notToHaveKey( "declaration", "explicit local.x should not have declaration property" ); + }); + + it( "should distinguish var x from local.x", function() { + var ast1 = astFromString( 'function test() { var x = 1; }', "script" ); + var ast2 = astFromString( 'function test() { local.x = 1; }', "script" ); + + var assignment1 = ast1.body[1].body.body[1]; + var assignment2 = ast2.body[1].body.body[1]; + + // Both produce MemberExpression with LOCAL.X, but var version should have declaration + expect( assignment1.left.type ).toBe( "MemberExpression" ); + expect( assignment2.left.type ).toBe( "MemberExpression" ); + + var hasDecl1 = structKeyExists( assignment1, "declaration" ) && assignment1.declaration == "var"; + var hasDecl2 = structKeyExists( assignment2, "declaration" ) && assignment2.declaration == "var"; + + expect( hasDecl1 ).toBeTrue( "var x should have declaration='var'" ); + expect( hasDecl2 ).toBeFalse( "local.x should NOT have declaration property" ); + }); + + it( "should preserve var keyword in for loop initializer", function() { + var ast = astFromString( 'for( var i = 1; i <= 10; i++ ) {}', "script" ); + var forStmt = ast.body[1]; + var init = forStmt.init; + + expect( init.type ).toBe( "AssignmentExpression" ); + expect( init ).toHaveKey( "declaration", "var in for loop should have declaration property" ); + expect( init.declaration ).toBe( "var" ); + }); + + it( "should preserve var keyword in for-in loop", function() { + var ast = astFromString( 'for( var item in arr ) {}', "script" ); + var forStmt = ast.body[1]; + + expect( forStmt.type ).toBe( "ForOfStatement" ); + expect( forStmt ).toHaveKey( "declaration", "var in for-in should have declaration property" ); + expect( forStmt.declaration ).toBe( "var" ); + }); + + }); + + describe( "2c. ScopeIdentifier node type", function() { + + it( "should use ScopeIdentifier for scope references", function() { + var ast = astFromString( 'request.myKey = 1;', "script" ); + var assignment = ast.body[1]; + var memberExpr = assignment.left; + + expect( memberExpr.type ).toBe( "MemberExpression" ); + var scopeId = memberExpr.object; + expect( scopeId.type ).toBe( "ScopeIdentifier", "scope reference should be ScopeIdentifier not Identifier" ); + expect( scopeId.name ).toBe( "REQUEST" ); + expect( scopeId ).notToHaveKey( "raw", "ScopeIdentifier should not have raw (synthetic node)" ); + }); + + it( "should use ScopeIdentifier for LOCAL scope from var keyword", function() { + var ast = astFromString( 'function test() { var x = 1; }', "script" ); + var func = ast.body[1]; + var assignment = func.body.body[1]; + var memberExpr = assignment.left; + + expect( memberExpr.type ).toBe( "MemberExpression" ); + var scopeId = memberExpr.object; + expect( scopeId.type ).toBe( "ScopeIdentifier", "LOCAL from var should be ScopeIdentifier" ); + expect( scopeId.name ).toBe( "LOCAL" ); + }); + + it( "should use ScopeIdentifier for explicit local scope", function() { + var ast = astFromString( 'function test() { local.x = 1; }', "script" ); + var func = ast.body[1]; + var assignment = func.body.body[1]; + var memberExpr = assignment.left; + + expect( memberExpr.type ).toBe( "MemberExpression" ); + var scopeId = memberExpr.object; + expect( scopeId.type ).toBe( "ScopeIdentifier", "explicit local should be ScopeIdentifier" ); + expect( scopeId.name ).toBe( "LOCAL" ); + }); + + it( "should use ScopeIdentifier for various scopes", function() { + var scopes = [ "session", "application", "url", "form", "cgi", "cookie", "server", "variables", "arguments" ]; + for ( var scopeName in scopes ) { + var ast = astFromString( '#scopeName#.key = 1;', "script" ); + var assignment = ast.body[1]; + var memberExpr = assignment.left; + var scopeId = memberExpr.object; + + expect( scopeId.type ).toBe( "ScopeIdentifier", "#scopeName# should be ScopeIdentifier" ); + expect( scopeId.name ).toBe( uCase( scopeName ), "#scopeName# should be uppercased" ); + } + }); + + }); + + describe( "2d. Implicit type annotations", function() { + + it( "should distinguish implicit any return type from explicit", function() { + var ast1 = astFromString( 'function test() {}', "script" ); + var ast2 = astFromString( 'any function test() {}', "script" ); + + var func1 = ast1.body[1]; + var func2 = ast2.body[1]; + + // Both have returnType.value = "any", but only explicit should have it + var hasExplicit1 = structKeyExists( func1.returnType, "explicit" ) && func1.returnType.explicit; + var hasExplicit2 = structKeyExists( func2.returnType, "explicit" ) && func2.returnType.explicit; + + expect( hasExplicit1 ).toBeFalse( "implicit any should not have explicit=true" ); + expect( hasExplicit2 ).toBeTrue( "explicit any should have explicit=true" ); + }); + + it( "should distinguish implicit any param type from explicit", function() { + var ast1 = astFromString( 'function test( arg ) {}', "script" ); + var ast2 = astFromString( 'function test( any arg ) {}', "script" ); + + var param1 = ast1.body[1].params[1]; + var param2 = ast2.body[1].params[1]; + + // Both have type.value = "any", but only explicit should have it + var hasExplicit1 = structKeyExists( param1.type, "explicit" ) && param1.type.explicit; + var hasExplicit2 = structKeyExists( param2.type, "explicit" ) && param2.type.explicit; + + expect( hasExplicit1 ).toBeFalse( "implicit any param should not have explicit=true" ); + expect( hasExplicit2 ).toBeTrue( "explicit any param should have explicit=true" ); + }); + + }); + + }); + + } + +} From 420c11f61b0536aada3827c69e83ad8a2d9af5a6 Mon Sep 17 00:00:00 2001 From: Zac Spitzer Date: Thu, 25 Dec 2025 03:37:58 +0100 Subject: [PATCH 71/71] LDEV-6036 add accessExplicit flag for AST source fidelity Track when access modifier (public/private/package/remote) was explicitly specified in source code vs defaulted. Similar to returnTypeExplicit for return types. --- .../lucee/runtime/type/util/KeyConstants.java | 1 + .../bytecode/statement/udf/Function.java | 10 ++ .../script/AbstrCFMLScriptTransformer.java | 8 +- test/tickets/LDEV6036.cfc | 153 ++++++++++++++++++ test/tickets/LDEV6036/functionNoModifiers.cfc | 21 +++ 5 files changed, 190 insertions(+), 3 deletions(-) create mode 100644 test/tickets/LDEV6036/functionNoModifiers.cfc diff --git a/core/src/main/java/lucee/runtime/type/util/KeyConstants.java b/core/src/main/java/lucee/runtime/type/util/KeyConstants.java index 804b69ebe8d..3924420e515 100644 --- a/core/src/main/java/lucee/runtime/type/util/KeyConstants.java +++ b/core/src/main/java/lucee/runtime/type/util/KeyConstants.java @@ -759,6 +759,7 @@ private static Collection.Key init(String key) { public static final Key _statustext = init("statustext"); public static final Key _extends = init("extends"); public static final Key _explicit = init("explicit"); + public static final Key _accessExplicit = init("accessExplicit"); public static final Key _implements = init("implements"); public static final Key __toDateTime = init("_toDateTime"); public static final Key __toNumeric = init("_toNumeric"); diff --git a/core/src/main/java/lucee/transformer/bytecode/statement/udf/Function.java b/core/src/main/java/lucee/transformer/bytecode/statement/udf/Function.java index 8af49c74692..9f97beea929 100644 --- a/core/src/main/java/lucee/transformer/bytecode/statement/udf/Function.java +++ b/core/src/main/java/lucee/transformer/bytecode/statement/udf/Function.java @@ -141,6 +141,7 @@ public abstract class Function extends StatementBaseNoFinal implements Opcodes, ExprBoolean bufferOutput; // ExprBoolean abstry=LitBoolean.FALSE; int access = Component.ACCESS_PUBLIC; + boolean accessExplicit; // LDEV-6036: track if access modifier was explicitly specified ExprString displayName; ExprString hint; String rawDocblock; // raw docblock text for AST round-tripping @@ -673,6 +674,11 @@ public int getIndex() { return index; } + // LDEV-6036: setter for tracking if access modifier was explicitly specified + public void setAccessExplicit(boolean accessExplicit) { + this.accessExplicit = accessExplicit; + } + public ExprString getName() { return name; } @@ -692,6 +698,10 @@ public void dump(Struct sct, String type) { if (a != null) { sct.setEL(KeyConstants._access, a); } + // LDEV-6036: Add explicit flag to distinguish "function test()" from "public function test()" + if (accessExplicit) { + sct.setEL(KeyConstants._accessExplicit, Boolean.TRUE); + } // modifier String m = toModifier(modifier); if (m != null) { diff --git a/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java b/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java index 4e0ce755176..25434f595bb 100755 --- a/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java +++ b/core/src/main/java/lucee/transformer/cfml/script/AbstrCFMLScriptTransformer.java @@ -837,7 +837,8 @@ else if (tokens[i] != null) { } } - // no access defined + // no access defined - track if it was explicit before applying default (LDEV-6036) + boolean accessExplicit = access != -1; if (access == -1) access = Component.ACCESS_PUBLIC; // Non access modifier @@ -909,6 +910,7 @@ else if (tokens[i].equalsIgnoreCase("static")) { } } Function res = closurePart(data, functionName, access, modifier, returnType, line, false); + res.setAccessExplicit(accessExplicit); // LDEV-6036: track if access modifier was explicitly specified if (isStatic) { if (data.context == CTX_INTERFACE) throw new TemplateException(data.srcCode, "static functions are not allowed within the interface body"); @@ -1392,8 +1394,8 @@ protected final Function lambdaPart(Data data, String id, int access, int modifi // add arguments for (Argument arg: args) { - func.addArgument(arg.getName(), arg.getType(), arg.getRequired(), arg.getDefaultValue(), arg.isPassByReference(), arg.getDisplayName(), arg.getHint(), - arg.getMetaData()); + // LDEV-6041: Use addArgument(Argument) to preserve typeExplicit flag + func.addArgument(arg); } comments(data); diff --git a/test/tickets/LDEV6036.cfc b/test/tickets/LDEV6036.cfc index 7832ff2a82a..86315026943 100644 --- a/test/tickets/LDEV6036.cfc +++ b/test/tickets/LDEV6036.cfc @@ -142,9 +142,162 @@ component extends="org.lucee.cfml.test.LuceeTestCase" labels="ast" { }); + describe( "Script-style functions", function() { + + it( "function without access should NOT have accessExplicit=true in AST", function() { + var ast = astFromPath( variables.testDir & "functionNoModifiers.cfc" ); + + // Find function with no modifiers + var fn = findFunction( ast, "noModifiers" ); + expect( fn ).notToBeNull( "noModifiers function should be found in AST" ); + + // access should exist (default public), but accessExplicit should NOT exist + expect( fn ).toHaveKey( "access", "function should have access (default)" ); + expect( fn ).notToHaveKey( "accessExplicit", "function without access in source should NOT have accessExplicit" ); + }); + + it( "function without returnType should have returnType.explicit=false in AST", function() { + var ast = astFromPath( variables.testDir & "functionNoModifiers.cfc" ); + + // Find function with no modifiers + var fn = findFunction( ast, "noModifiers" ); + expect( fn ).notToBeNull( "noModifiers function should be found in AST" ); + + // returnType should exist (default any), but explicit should be false/missing + expect( fn ).toHaveKey( "returnType", "function should have returnType (default)" ); + expect( fn.returnType ).notToHaveKey( "explicit", "function without returnType in source should NOT have returnType.explicit" ); + }); + + it( "function WITH explicit public should have accessExplicit=true in AST", function() { + var ast = astFromPath( variables.testDir & "functionNoModifiers.cfc" ); + + var fn = findFunction( ast, "explicitPublic" ); + expect( fn ).notToBeNull( "explicitPublic function should be found in AST" ); + + // Should have access and accessExplicit since it was explicitly in source + expect( fn ).toHaveKey( "access", "function with explicit public should have access in AST" ); + expect( fn.access ).toBe( "public" ); + expect( fn ).toHaveKey( "accessExplicit", "function with explicit public should have accessExplicit" ); + expect( fn.accessExplicit ).toBeTrue( "accessExplicit should be true for explicit public" ); + }); + + it( "function WITH explicit returnType should have returnType.explicit=true in AST", function() { + var ast = astFromPath( variables.testDir & "functionNoModifiers.cfc" ); + + var fn = findFunction( ast, "explicitAnyReturn" ); + expect( fn ).notToBeNull( "explicitAnyReturn function should be found in AST" ); + + // Should have returnType with explicit=true since it was explicitly in source + expect( fn ).toHaveKey( "returnType", "function with explicit any return should have returnType in AST" ); + expect( fn.returnType.value ).toBe( "any" ); + expect( fn.returnType ).toHaveKey( "explicit", "function with explicit any should have returnType.explicit" ); + expect( fn.returnType.explicit ).toBeTrue( "returnType.explicit should be true for explicit any" ); + }); + + it( "function WITH explicit public any should have both explicit flags in AST", function() { + var ast = astFromPath( variables.testDir & "functionNoModifiers.cfc" ); + + var fn = findFunction( ast, "explicitBoth" ); + expect( fn ).notToBeNull( "explicitBoth function should be found in AST" ); + + expect( fn ).toHaveKey( "access", "function with explicit public should have access in AST" ); + expect( fn.access ).toBe( "public" ); + expect( fn ).toHaveKey( "accessExplicit", "function with explicit public should have accessExplicit" ); + expect( fn.accessExplicit ).toBeTrue( "accessExplicit should be true" ); + + expect( fn ).toHaveKey( "returnType", "function with explicit any return should have returnType in AST" ); + expect( fn.returnType.value ).toBe( "any" ); + expect( fn.returnType ).toHaveKey( "explicit", "function with explicit any should have returnType.explicit" ); + expect( fn.returnType.explicit ).toBeTrue( "returnType.explicit should be true" ); + }); + + it( "function with only returnType should NOT have accessExplicit in AST", function() { + var ast = astFromPath( variables.testDir & "functionNoModifiers.cfc" ); + + var fn = findFunction( ast, "onlyReturnType" ); + expect( fn ).notToBeNull( "onlyReturnType function should be found in AST" ); + + // access should exist (default), but accessExplicit should NOT exist + expect( fn ).toHaveKey( "access", "function should have access (default)" ); + expect( fn ).notToHaveKey( "accessExplicit", "function without access in source should NOT have accessExplicit" ); + + // Should have returnType with explicit=true since it was explicitly in source + expect( fn ).toHaveKey( "returnType", "function with explicit string return should have returnType in AST" ); + expect( fn.returnType.value ).toBe( "string" ); + expect( fn.returnType ).toHaveKey( "explicit", "function with explicit string should have returnType.explicit" ); + expect( fn.returnType.explicit ).toBeTrue( "returnType.explicit should be true" ); + }); + + it( "function with only access should NOT have returnType.explicit in AST", function() { + var ast = astFromPath( variables.testDir & "functionNoModifiers.cfc" ); + + var fn = findFunction( ast, "onlyAccess" ); + expect( fn ).notToBeNull( "onlyAccess function should be found in AST" ); + + // Should have access and accessExplicit since it was explicitly in source + expect( fn ).toHaveKey( "access", "function with explicit private should have access in AST" ); + expect( fn.access ).toBe( "private" ); + expect( fn ).toHaveKey( "accessExplicit", "function with explicit private should have accessExplicit" ); + expect( fn.accessExplicit ).toBeTrue( "accessExplicit should be true for explicit private" ); + + // returnType should exist (default), but explicit should NOT exist + expect( fn ).toHaveKey( "returnType", "function should have returnType (default)" ); + expect( fn.returnType ).notToHaveKey( "explicit", "function without returnType in source should NOT have returnType.explicit" ); + }); + + }); + }); } + /** + * Find a function by name in the AST + */ + private function findFunction( required struct node, required string name ) { + var nodeType = node.type ?: ""; + + // Check if this is a FunctionDeclaration with matching name + if ( nodeType == "FunctionDeclaration" ) { + var fnName = ""; + // AST uses "name" with StringLiteral containing function name + if ( structKeyExists( node, "name" ) && isStruct( node.name ) ) { + fnName = node.name.value ?: ""; + } + if ( uCase( fnName ) == uCase( name ) ) { + return node; + } + } + + // Recurse into body + if ( structKeyExists( node, "body" ) ) { + if ( isStruct( node.body ) ) { + var result = findFunction( node.body, name ); + if ( !isNull( result ) ) return result; + } else if ( isArray( node.body ) ) { + for ( var child in node.body ) { + if ( isStruct( child ) ) { + var result = findFunction( child, name ); + if ( !isNull( result ) ) return result; + } + } + } + } + + // Recurse into body.body (for components) + if ( structKeyExists( node, "body" ) && isStruct( node.body ) && structKeyExists( node.body, "body" ) ) { + if ( isArray( node.body.body ) ) { + for ( var child in node.body.body ) { + if ( isStruct( child ) ) { + var result = findFunction( child, name ); + if ( !isNull( result ) ) return result; + } + } + } + } + + return; + } + /** * Find a property by name in the AST */ diff --git a/test/tickets/LDEV6036/functionNoModifiers.cfc b/test/tickets/LDEV6036/functionNoModifiers.cfc new file mode 100644 index 00000000000..bdf714c4852 --- /dev/null +++ b/test/tickets/LDEV6036/functionNoModifiers.cfc @@ -0,0 +1,21 @@ +component { + // No modifiers at all - should NOT have access or returnType in AST + function noModifiers() {} + + // Explicit public only - should have access="public" but NOT returnType + public function explicitPublic() {} + + // Explicit any return only - should have returnType="any" but NOT access + any function explicitAnyReturn() {} + + // Both explicit - should have both access="public" and returnType="any" + public any function explicitBoth() {} + + // Only return type (string) - should have returnType but NOT access + string function onlyReturnType() { + return ""; + } + + // Only access (private) - should have access but NOT returnType + private function onlyAccess() {} +}