From 288a1bb1fcc247e2f1e1caec2765cf97cf13820b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 04:49:51 +0000 Subject: [PATCH] Lex Lucee's backtick template block ``` drops out of cfscript into template markup, the mirror of dropping the other way. The backtick had no lexer rule at all, so the fences vanished and the HTML between them was parsed as cfscript. A lexer mode, built the way the string modes already are: OPEN_TEMPLATE pushes InTemplate, CLOSE_TEMPLATE pops, and '#' pushes HashMode plus DefaultMode so interpolation works exactly as it does inside a quoted string. The body is markup, not cfscript, so CFTemplateBlockStatement keeps it as its source text and writes it back unchanged. What a consumer needs is the interpolations, and those are parsed: `#name#` and `#user.age#` come back through getExpressions() and decomposeExpression(), so a variable used only inside a block still reads as a use. Without the visitor the block parsed with zero errors and then disappeared -- the markup gone from the tree, the interpolations leaking out as bare statements. That is the trap CLAUDE.md describes, and the reason the check here is the decompiled output rather than the error count. Differential harness 8 disagreements to 7, nothing newly broken. Verified end to end -- 313 cfparser tests, ./gradlew build, CFLint's 675. Closes #48 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GzpZFd4rnE1Yi2sVHAji35 --- .../src/main/antlr4/cfml/CFSCRIPTLexer.g4 | 18 ++- .../src/main/antlr4/cfml/CFSCRIPTParser.g4 | 11 ++ .../script/CFTemplateBlockStatement.java | 62 ++++++++ .../walker/CFScriptStatementVisitor.java | 24 +++ .../tests/expressions/template_block_48.cfc | 7 + .../template_block_48.expected.txt | 144 ++++++++++++++++++ 6 files changed, 265 insertions(+), 1 deletion(-) create mode 100644 cfml.parsing/src/main/java/cfml/parsing/cfscript/script/CFTemplateBlockStatement.java create mode 100644 cfml.parsing/src/test/resources/cfml/tests/expressions/template_block_48.cfc create mode 100644 cfml.parsing/src/test/resources/cfml/tests/expressions/template_block_48.expected.txt diff --git a/cfml.parsing/src/main/antlr4/cfml/CFSCRIPTLexer.g4 b/cfml.parsing/src/main/antlr4/cfml/CFSCRIPTLexer.g4 index c3e04e9..6c4469e 100644 --- a/cfml.parsing/src/main/antlr4/cfml/CFSCRIPTLexer.g4 +++ b/cfml.parsing/src/main/antlr4/cfml/CFSCRIPTLexer.g4 @@ -168,6 +168,9 @@ FINAL: [fF][iI][nN][aA][lL]; ABSTRACT: [aA][Bb][sS][tT][Rr][aA][cC][tT]; STATIC: [sS][tT][aA][tT][iI][cC]; SCRIPTOPEN: '<' [cC] [fF] [sS] [cC] [rR] [iI] [pP] [tT] '>'; +// Lucee's template block: ``` drops out of cfscript into template markup, the mirror of +// dropping the other way. The body interpolates #...# like a string does. +OPEN_TEMPLATE: '```' -> pushMode(InTemplate); SCRIPTCLOSE:''; // operators DOT: '.'; @@ -456,4 +459,17 @@ HASH_SINGLE mode HashMode; HashMode_ANY: -> popMode,skip; - \ No newline at end of file + +mode InTemplate; +CLOSE_TEMPLATE + : '```' -> popMode +; +TEMPLATE_DOUBLEHASH + : '##' -> type(DOUBLEHASH) +; +TEMPLATE_LITERAL + : (~[`#]+ | '`' ~[`] | '``' ~[`])+ +; +TEMPLATE_HASH + : '#' -> type(POUND_SIGN),pushMode(HashMode),pushMode(DefaultMode) +; diff --git a/cfml.parsing/src/main/antlr4/cfml/CFSCRIPTParser.g4 b/cfml.parsing/src/main/antlr4/cfml/CFSCRIPTParser.g4 index cd5cbe6..f2514ac 100644 --- a/cfml.parsing/src/main/antlr4/cfml/CFSCRIPTParser.g4 +++ b/cfml.parsing/src/main/antlr4/cfml/CFSCRIPTParser.g4 @@ -18,6 +18,16 @@ cfscriptBlock : SCRIPTOPEN scriptBlock SCRIPTCLOSE ; +// The inverse of cfscriptBlock: markup embedded in cfscript, between ``` fences, with +// #...# interpolation as in a string literal. +templateBlock + : OPEN_TEMPLATE (templateLiteralPart | POUND_SIGN anExpression POUND_SIGN)* CLOSE_TEMPLATE + ; + +templateLiteralPart + : TEMPLATE_LITERAL | DOUBLEHASH + ; + componentDeclaration : componentModifier? COMPONENT componentAttribute* componentGuts //-> ( COMPDECL componentAttribute* componentGuts) ; @@ -154,6 +164,7 @@ statement | startExpression SEMICOLON | SEMICOLON // empty statement | functionCall // without semi + | templateBlock ; endOfStatement diff --git a/cfml.parsing/src/main/java/cfml/parsing/cfscript/script/CFTemplateBlockStatement.java b/cfml.parsing/src/main/java/cfml/parsing/cfscript/script/CFTemplateBlockStatement.java new file mode 100644 index 0000000..aa71c3f --- /dev/null +++ b/cfml.parsing/src/main/java/cfml/parsing/cfscript/script/CFTemplateBlockStatement.java @@ -0,0 +1,62 @@ +package cfml.parsing.cfscript.script; + +import java.util.ArrayList; +import java.util.List; + +import org.antlr.v4.runtime.Token; + +import cfml.parsing.cfscript.CFExpression; +import cfml.parsing.util.ArrayBuilder; + +/** + * Lucee's template block: markup embedded in cfscript between ``` fences, the mirror + * of <cfscript> dropping the other way. + * + * The body is markup rather than cfscript, so it is kept as its source text and written back + * unchanged. What a consumer needs from it is the interpolated #...# expressions, + * which are parsed and reachable through {@link #getExpressions()} and {@link + * #decomposeExpression()} -- a variable used only inside a template block is still a use. + */ +public class CFTemplateBlockStatement extends CFParsedStatement { + + private static final long serialVersionUID = 1L; + + private static final String FENCE = "```"; + + private String content; + private List expressions; + + public CFTemplateBlockStatement(Token _t, String _content, List _expressions) { + super(_t); + content = _content; + expressions = _expressions == null ? new ArrayList() : _expressions; + for (CFExpression expression : expressions) { + expression.setParent(this); + } + } + + /** The markup between the fences, exactly as written, interpolations included. */ + public String getContent() { + return content; + } + + /** The #...# expressions inside the block, in source order. */ + public List getExpressions() { + return expressions; + } + + @Override + public String Decompile(int indent) { + return FENCE + content + FENCE; + } + + @Override + public List decomposeExpression() { + return expressions; + } + + @Override + public List decomposeScript() { + return ArrayBuilder.createCFScriptStatement(); + } +} diff --git a/cfml.parsing/src/main/java/cfml/parsing/cfscript/walker/CFScriptStatementVisitor.java b/cfml.parsing/src/main/java/cfml/parsing/cfscript/walker/CFScriptStatementVisitor.java index 8e543f1..653b841 100644 --- a/cfml.parsing/src/main/java/cfml/parsing/cfscript/walker/CFScriptStatementVisitor.java +++ b/cfml.parsing/src/main/java/cfml/parsing/cfscript/walker/CFScriptStatementVisitor.java @@ -8,10 +8,13 @@ import java.util.Stack; import org.antlr.v4.runtime.ParserRuleContext; +import org.antlr.v4.runtime.Token; +import org.antlr.v4.runtime.misc.Interval; import org.antlr.v4.runtime.tree.ParseTree; import cfml.CFSCRIPTParser.AbortStatementContext; import cfml.CFSCRIPTParser.AdminStatementContext; +import cfml.CFSCRIPTParser.AnExpressionContext; import cfml.CFSCRIPTParser.AnonymousFunctionDeclarationContext; import cfml.CFSCRIPTParser.AssignmentExpressionContext; import cfml.CFSCRIPTParser.BaseExpressionContext; @@ -52,6 +55,7 @@ import cfml.CFSCRIPTParser.StartExpressionContext; import cfml.CFSCRIPTParser.StatementContext; import cfml.CFSCRIPTParser.SwitchStatementContext; +import cfml.CFSCRIPTParser.TemplateBlockContext; import cfml.CFSCRIPTParser.TagFunctionStatementContext; import cfml.CFSCRIPTParser.TagStatementContext; import cfml.CFSCRIPTParser.TagThrowStatementContext; @@ -95,6 +99,7 @@ import cfml.parsing.cfscript.script.CFScriptStatement; import cfml.parsing.cfscript.script.CFSwitchStatement; import cfml.parsing.cfscript.script.CFTagThrowStatement; +import cfml.parsing.cfscript.script.CFTemplateBlockStatement; import cfml.parsing.cfscript.script.CFThreadStatement; import cfml.parsing.cfscript.script.CFThrowStatement; import cfml.parsing.cfscript.script.CFTransactionStatement; @@ -585,6 +590,25 @@ public CFScriptStatement visitExitStatement(ExitStatementContext ctx) { return exitStatement; } + @Override + public CFScriptStatement visitTemplateBlock(TemplateBlockContext ctx) { + // The body is markup, not cfscript, so it is kept verbatim rather than modelled. Only the + // interpolations are parsed -- without a visitor here the block vanished from the tree and + // its #...# expressions leaked out as bare statements. + List expressions = new ArrayList(); + for (AnExpressionContext interpolation : ctx.anExpression()) { + CFExpression expression = cfExpressionVisitor.visit(interpolation); + if (expression != null) { + expressions.add(expression); + } + } + Token open = ctx.OPEN_TEMPLATE().getSymbol(); + Token close = ctx.CLOSE_TEMPLATE().getSymbol(); + String content = open.getInputStream().getText( + Interval.of(open.getStopIndex() + 1, close.getStartIndex() - 1)); + return new CFTemplateBlockStatement(open, content, expressions); + } + @Override public CFScriptStatement visitParamStatement(ParamStatementContext ctx) { // System.out.println("visitParamStatement"); diff --git a/cfml.parsing/src/test/resources/cfml/tests/expressions/template_block_48.cfc b/cfml.parsing/src/test/resources/cfml/tests/expressions/template_block_48.cfc new file mode 100644 index 0000000..d7d0b54 --- /dev/null +++ b/cfml.parsing/src/test/resources/cfml/tests/expressions/template_block_48.cfc @@ -0,0 +1,7 @@ +name = "World"; +user = { age: 30 }; +``` +

Hello #name#

+

You are #user.age# years old

+``` +after = 1; diff --git a/cfml.parsing/src/test/resources/cfml/tests/expressions/template_block_48.expected.txt b/cfml.parsing/src/test/resources/cfml/tests/expressions/template_block_48.expected.txt new file mode 100644 index 0000000..07c701a --- /dev/null +++ b/cfml.parsing/src/test/resources/cfml/tests/expressions/template_block_48.expected.txt @@ -0,0 +1,144 @@ +/*===TOKENS===*/ +IDENTIFIER +'=' <=> +OPEN_STRING <"> +STRING_LITERAL +CLOSE_STRING <"> +';' <;> +Hidden:NEWLINE <> +IDENTIFIER +'=' <=> +'{' <{> +IDENTIFIER +':' <:> +INTEGER_LITERAL <30> +'}' <}> +';' <;> +Hidden:NEWLINE <> +OPEN_TEMPLATE <```> +TEMPLATE_LITERAL <

Hello> +'#' <#> +IDENTIFIER +'#' <#> +TEMPLATE_LITERAL <

+

You are> +'#' <#> +IDENTIFIER +'.' <.> +IDENTIFIER +'#' <#> +TEMPLATE_LITERAL > +CLOSE_TEMPLATE <```> +Hidden:NEWLINE <> +IDENTIFIER +'=' <=> +INTEGER_LITERAL <1> +';' <;> +Hidden:NEWLINE <> +/*===TREE===*/ +(scriptBlock + (element + (statement + (assignmentExpression + (startExpression + (baseExpression (unaryExpression (memberExpression (identifier name)))) + ) + = + (startExpression + (baseExpression + (unaryExpression + (primaryExpression + (literalExpression (stringLiteral " (stringLiteralPart World) ")) + ) + ) + ) + ) + ) + (endOfStatement ;) + ) + ) + (element + (statement + (assignmentExpression + (startExpression + (baseExpression (unaryExpression (memberExpression (identifier user)))) + ) + = + (startExpression + (baseExpression + (unaryExpression + (primaryExpression + (implicitStruct + { + (implicitStructElements + (implicitStructExpression + (implicitStructKeyExpression (multipartIdentifier (identifier age))) + : + (baseExpression (unaryExpression (primaryExpression (literalExpression 30)))) + ) + ) + } + ) + ) + ) + ) + ) + ) + (endOfStatement ;) + ) + ) + (element + (statement + (templateBlock + ``` + (templateLiteralPart '\n

Hello ') + # + (anExpression + (startExpression + (baseExpression (unaryExpression (memberExpression (identifier name)))) + ) + ) + # + (templateLiteralPart '

\n

You are ') + # + (anExpression + (startExpression + (baseExpression + (unaryExpression (memberExpression (identifier user) . (identifier age))) + ) + ) + ) + # + (templateLiteralPart ' years old

\n') + ``` + ) + ) + ) + (element + (statement + (assignmentExpression + (startExpression + (baseExpression (unaryExpression (memberExpression (identifier after)))) + ) + = + (startExpression + (baseExpression (unaryExpression (primaryExpression (literalExpression 1)))) + ) + ) + (endOfStatement ;) + ) + ) +) +/*======*/ +/*===DECOMPILE===*/ +{ +name = 'World'; +user = {age:30}; +``` +

Hello #name#

+

You are #user.age# years old

+```; +after = 1; + +} +/*======*/ \ No newline at end of file