fix(cfml): <cfinclude template> and cfinclude() link the code to the file they include (#2838)

Only cfscript's `include "x.cfm";`, written outside any function, linked
the including code to the template. CfmlExtractor never read
`<cfinclude template="...">`, which the grammar parses as a
cf_selfclose_tag whose name is only in the text after `<cf`, so the tag
form made nothing anywhere. cfscript's include hook read only the string
form, `cfinclude(template="x.cfm")` went through the call extractor as a
call to `cfinclude`, and the function-body walker never extracts import
statements, so an `include` in a function was dropped. Resolution went
through the generic pipeline: the file-name matcher links a relative path
like `../shared/helpers.cfm` only when no other file shares its name, and
past it the symbol strategies could take another template's import node
of the same path, or the including file itself.

Fix:
- `<cfinclude template="...">` is extracted like the script form: an
  import node in the scope it sits in (function, component, or the
  template's file) and an imports reference from that scope.
  `cfinclude(...)` and `include template="...";` make the same import and
  no call, and an `include` in a function body counts, from the function.
- A path built at runtime (`#dir#/x.cfm`) is no import, in every form.
- A CFML include of a template path resolves as CFML does: relative to the
  including file's directory, or from the project root for a leading `/`
  (less sure, 0.85), to the file at that path, ignoring case when one file
  matches. A path that names no file there goes to the file-name matcher,
  as before, but only ever to a file, and never to the including file.

Validation:
- New cfml-include.test.ts: the tag form at component level (same folder,
  ../, upper case, unquoted), in a function body inside <cfif>, in a
  template and from the web root; the script forms; runtime-built paths,
  cfinclude(template=variables.x) and <cfmodule> (no import); a relative
  path with a namesake elsewhere; an unmatched path that another template
  also includes, and a path whose only namesake is the includer. 5 of 7
  fail on main, all 7 pass.
- A/B vs main 51c4124d (CFML has no kernel path) on FW/1, BlogCFC,
  ColdBox, ContentBox, Preside, CFWheels and its vendor/wheels core as a
  project of its own: 127 new imports edges and 152 import nodes (36
  more resolved again to the same file, more surely); 9 import nodes of
  runtime-built paths gone; 1 edge gone, CFWheels' starter-app
  views/wheels/wheels.cfm including itself. No edge moved to another
  target. Every CFML imports edge in the fix graphs (225) targets a file
  named like the include path, and every relative path that stays inside
  the project reaches exactly that file but one: Wheels' Public.cfc
  including views/routetesterprocess.cfm, no file beside it, matched by
  name to public/views/routetesterprocess.cfm as before. express and flask
  controls byte-identical.
- Index time, median of 4 interleaved runs, CPU with workers, main vs
  fix: Preside 52.6 / 50.8 s, Wheels core 28.2 / 29.5 s. Within noise.

Refs #2651 (part 1; part 2 is a feature request and stays open)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-23 09:59:26 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent cf3e6bc525
commit d90f6abec7
6 changed files with 346 additions and 5 deletions
+63 -2
View File
@@ -417,6 +417,11 @@ export class CfmlExtractor {
case 'cf_function_tag':
if (declScope) this.extractFunctionTag(node, parentClassName ? containerId : undefined, containerId, parentClassName);
return;
case 'cf_selfclose_tag':
// `<cfinclude template="…">`, wherever it is written (#2651). Its
// attributes are still walked below for `#hash#` expressions.
if (this.selfCloseTagName(node) === 'include') this.extractIncludeTag(node, containerId);
break;
case 'cf_tag': {
const name = this.cfTagName(node);
if (name === 'interface' && declScope && !parentClassName) {
@@ -559,6 +564,60 @@ export class CfmlExtractor {
return nameNode ? this.source.substring(nameNode.startIndex, nameNode.endIndex).toLowerCase() : undefined;
}
/**
* A void tag's name, lowercased, without the `cf` prefix (`<cfInclude …>` →
* `include`). The grammar keeps no node for it: it is the text after `<cf`.
*/
private selfCloseTagName(tag: SyntaxNode): string | undefined {
const match = /^<cf([A-Za-z_]\w*)/i.exec(this.source.slice(tag.startIndex, tag.startIndex + 64));
return match?.[1]?.toLowerCase();
}
/**
* `<cfinclude template="formlistshared.cfm">` (#2651) includes the template
* as cfscript's `include "formlistshared.cfm";` does, so it is extracted the
* same way: an import node in the scope it sits in (the function, the
* component, or the file of a template) and an `imports` reference from
* that scope, which the resolver links to the file. A template built at
* runtime (`template="#dir#/x.cfm"`) names no file the index can link.
*/
private extractIncludeTag(tag: SyntaxNode, scopeId: string): void {
const valueNode = this.tagAttrValueNode(tag, 'template', true);
const template = valueNode ? this.source.substring(valueNode.startIndex, valueNode.endIndex).trim() : '';
if (!template) return;
// Qualified like the generic extractor's import nodes: under the scope's
// own name, without the file path (`baseform::formlistshared.cfm`).
const scope = this.nodes.find((n) => n.id === scopeId);
const prefix = !scope || scope.kind === 'file' ? '' : scope.kind === 'method' ? scope.qualifiedName : scope.name;
const line = tag.startPosition.row + 1;
const column = tag.startPosition.column;
const id = this.nodeIds.generate(this.filePath, 'import', template, line, column);
this.nodes.push({
id,
kind: 'import',
name: template,
qualifiedName: prefix ? `${prefix}::${template}` : template,
filePath: this.filePath,
language: this.language,
startLine: line,
endLine: tag.endPosition.row + 1,
startColumn: column,
endColumn: tag.endPosition.column,
signature: this.source.substring(tag.startIndex, tag.endIndex).trim(),
updatedAt: Date.now(),
});
this.edges.push({ source: scopeId, target: id, kind: 'contains' });
this.unresolvedReferences.push({
fromNodeId: scopeId,
referenceName: template,
referenceKind: 'imports',
filePath: this.filePath,
line,
column,
language: this.language,
});
}
/**
* Delegate a `<cfscript>...</cfscript>` tag body to the cfscript grammar.
* With `parentClassName` set (the block sits at component scope), functions
@@ -691,9 +750,10 @@ export class CfmlExtractor {
/**
* The `attribute_value` node of a tag attribute — `undefined` when the tag
* has no such attribute, `null` when its value isn't plain text (empty, or
* a `#hash#` expression).
* a `#hash#` expression). With `wholeValue`, also `null` when only part of
* the value is plain text (`template="#dir#/x.cfm"`).
*/
private tagAttrValueNode(tag: SyntaxNode, attrName: string): SyntaxNode | null | undefined {
private tagAttrValueNode(tag: SyntaxNode, attrName: string, wholeValue = false): SyntaxNode | null | undefined {
const attrs: SyntaxNode[] = [];
for (let i = 0; i < tag.namedChildCount; i++) {
const child = tag.namedChild(i);
@@ -716,6 +776,7 @@ export class CfmlExtractor {
const valueWrapper = attr.namedChildren.find(
(c: SyntaxNode) => c.type === 'quoted_cf_attribute_value' || c.type === 'cf_attribute_value'
);
if (wholeValue && valueWrapper?.namedChildCount !== 1) return null;
return valueWrapper?.namedChildren.find((c: SyntaxNode) => c.type === 'attribute_value') ?? null;
}
return undefined;
+49 -3
View File
@@ -17,6 +17,44 @@ function cfmlVisibility(node: SyntaxNode): 'public' | 'private' | 'protected' |
return undefined;
}
/**
* A string literal's text when it is written out in full, or null when a
* `#…#` expression builds it at runtime (`"#dir#/x.cfm"`): such a path names
* no file the index can link.
*/
function literalString(node: SyntaxNode | null | undefined, source: string): string | null {
if (node?.type !== 'string' || node.namedChildren.some((c: SyntaxNode) => c.type !== 'string_fragment')) return null;
return getNodeText(node, source).replace(/^["']|["']$/g, '').trim() || null;
}
/** The value of a `template=…` argument or attribute, when it is a literal string. */
function templateArgument(node: SyntaxNode, source: string): string | null {
if (node.type !== 'assignment_expression') return null;
const left = getChildByField(node, 'left');
if (left?.type !== 'identifier' || getNodeText(left, source).toLowerCase() !== 'template') return null;
return literalString(getChildByField(node, 'right'), source);
}
/**
* Whether a call is `cfinclude(…)` — the function form of `include` and of
* `<cfinclude>` (ACF 11+, Lucee): an include of a template, not a call (#2651).
*/
export function isCfIncludeCall(node: SyntaxNode, source: string): boolean {
const fn = getChildByField(node, 'function');
return fn?.type === 'identifier' && getNodeText(fn, source).toLowerCase() === 'cfinclude';
}
/** The literal template `cfinclude(template="x.cfm")` or `cfinclude("x.cfm")` names. */
function cfIncludeCallTemplate(node: SyntaxNode, source: string): string | null {
const args = getChildByField(node, 'arguments');
if (!args) return null;
for (const arg of args.namedChildren) {
const named = templateArgument(arg, source);
if (named) return named;
}
return literalString(args.namedChildren[0], source);
}
export const cfscriptExtractor: LanguageExtractor = {
functionTypes: ['function_declaration', 'function_expression', 'arrow_function'],
classTypes: ['component'],
@@ -45,10 +83,18 @@ export const cfscriptExtractor: LanguageExtractor = {
const importText = source.substring(node.startIndex, node.endIndex).trim();
if (node.type === 'include_statement') {
// `include "path/to/file.cfm";` — the included template path.
// `include "path/to/file.cfm";` — the included template path — or the
// attribute form, `include template="path/to/file.cfm";` (#2651). A
// path built at runtime (`include "#view#";`) is no import.
const expr = node.namedChildren.find((c: SyntaxNode) => c.type === 'string');
if (!expr) return null;
const moduleName = getNodeText(expr, source).replace(/^["']|["']$/g, '');
const attr = expr ? undefined : node.namedChildren.find((c: SyntaxNode) => c.type === 'assignment_expression');
const moduleName = expr ? literalString(expr, source) : attr ? templateArgument(attr, source) : null;
return moduleName ? { moduleName, signature: importText } : null;
}
if (node.type === 'call_expression') {
// `cfinclude(template="path/to/file.cfm");` (#2651).
const moduleName = isCfIncludeCall(node, source) ? cfIncludeCallTemplate(node, source) : null;
return moduleName ? { moduleName, signature: importText } : null;
}
+12
View File
@@ -27,6 +27,7 @@ import { appendCppMacroMethods } from './languages/cpp-macro-methods';
import { rustImplItemOwner, rustImplTypeName } from './languages/rust';
import { goAliasTypeNames, goEmbeddedTypeName, goInterfaceMethodTypeNames, goStructFieldTypeNames } from './languages/go';
import { dartMisparsedGenericCall, dartReceiverOf, isDartTypeName, pushDartTypeAliasRefs, pushDartTypeRefs } from './languages/dart';
import { isCfIncludeCall } from './languages/cfscript';
import { LiquidExtractor } from './liquid-extractor';
import { RazorExtractor } from './razor-extractor';
import { SvelteExtractor } from './svelte-extractor';
@@ -5166,6 +5167,13 @@ export class TreeSitterExtractor {
const callerId = this.nodeStack[this.nodeStack.length - 1];
if (!callerId) return;
// CFML `cfinclude(template="x.cfm")` includes a template, as `include
// "x.cfm";` does (#2651): an import of the file, never a call.
if (this.language === 'cfscript' && isCfIncludeCall(node, this.source)) {
this.extractImport(node);
return;
}
// VB.NET: `foo(args)` is syntactically ambiguous between a call and an
// index read, so the grammar parses non-empty parens as
// array_access_expression (field `array`, not `function`) — even Roslyn
@@ -7249,6 +7257,10 @@ export class TreeSitterExtractor {
// handler paths live in a raw token tree the call walker can't see.
if (nodeType === 'macro_invocation') this.extractRustRouteMacro(node);
// A CFML `include "x.cfm";` in a function body includes the template
// there, as it does at component scope (#2651).
if (nodeType === 'include_statement' && this.language === 'cfscript') this.extractImport(node);
if (this.extractor!.callTypes.includes(nodeType)) {
this.extractCall(node);
} else if (INSTANTIATION_KINDS.has(nodeType) || this.isVbnetConstructorShapedArrayCreation(node)) {
+59
View File
@@ -1568,6 +1568,21 @@ export class ReferenceResolver {
return this.resolveCfmlComponentPath(ref);
}
// A CFML include names a template by its path from the including file's
// directory, or from the web root (#2651): `<cfinclude template="../shared/
// helpers.cfm">` is that file, not whichever `helpers.cfm` file-name
// matching would settle on. A path that names no file here (one through a
// server mapping, `/contenscms/…`) is matched by its file name, as before,
// but only to a file: the symbol strategies below took a path no file
// matched for another template's import statement of the same path. Nor
// is it the including file, which a file-name match can land on (Wheels'
// `views/wheels/wheels.cfm` including `../../wheels/public/wheels.cfm`).
if ((ref.language === 'cfml' || ref.language === 'cfscript') && ref.referenceKind === 'imports' && isCfmlTemplatePath(ref.referenceName)) {
const included = this.resolveCfmlIncludePath(ref) ?? matchByFilePath(ref, this.context);
if (included && this.nodeById(included.targetNodeId)?.filePath === ref.filePath) return null;
return this.gateLanguage(included, ref);
}
// A PHP class written with a namespace in it — `new Alias\X()` through a
// `use Ns as Alias;` namespace alias, `extends Sub\Base`, `\Ns\X::make()`
// (#2256). The pre-filter below would drop most of these before any import
@@ -3581,6 +3596,41 @@ export class ReferenceResolver {
return { original: ref, targetNodeId: tied[0]!.id, confidence: 0.9, resolvedBy: 'qualified-name' };
}
/**
* The file a CFML include names (#2651) — `include "x.cfm";`,
* `<cfinclude template="x.cfm">`, `cfinclude(template="x.cfm")`. A template
* path is relative to the directory of the file that includes it
* (`formlistshared.cfm`, `../shared/helpers.cfm`), or to the web root when
* it starts with `/`: the project root, when the project is the web root —
* an assumption, so such a match is less sure. Null when no indexed file is
* there. CFML code written on a case-blind file system often spells a path
* in another case, so a single file that matches ignoring case counts too.
*/
private resolveCfmlIncludePath(ref: UnresolvedRef): ResolvedRef | null {
const written = ref.referenceName.trim().replace(/\\/g, '/');
const fromRoot = written.startsWith('/');
const parts = fromRoot ? [] : ref.filePath.replace(/\\/g, '/').split('/').slice(0, -1);
for (const seg of written.split('/')) {
if (seg === '' || seg === '.') continue;
if (seg === '..') {
if (parts.length === 0) return null; // above the project root
parts.pop();
} else {
parts.push(seg);
}
}
const fileName = parts[parts.length - 1];
if (!fileName) return null;
const wanted = parts.join('/');
const files = this.context.getNodesByLowerName(fileName.toLowerCase()).filter((n) => n.kind === 'file');
const exact = files.find((n) => n.filePath === wanted);
if (exact) return { original: ref, targetNodeId: exact.id, confidence: fromRoot ? 0.85 : 0.95, resolvedBy: 'file-path' };
const folded = files.filter((n) => n.filePath.toLowerCase() === wanted.toLowerCase());
return folded.length === 1
? { original: ref, targetNodeId: folded[0]!.id, confidence: fromRoot ? 0.8 : 0.9, resolvedBy: 'file-path' }
: null;
}
/**
* Resolve a `this.<member>` function-as-value reference (#756/#808) to the
* ENCLOSING CLASS's own member — never a same-named symbol elsewhere. The
@@ -4267,6 +4317,15 @@ export class ReferenceResolver {
}
}
/**
* Whether a CFML `imports` name is the path of a template an include names
* (`formlistshared.cfm`, `../shared/helpers.cfm`, `/app/x.cfm`), not a
* cfscript `import com.foo.Bar;` or `import "java:…";` (#2651).
*/
function isCfmlTemplatePath(name: string): boolean {
return !name.includes(':') && (name.includes('/') || name.includes('\\') || /\.(?:cfml?|cfc|cfs)$/i.test(name.trim()));
}
/** The project-relative directory holding `p` ('' for the project root). */
function goDirOf(p: string): string {
const dir = path.posix.dirname(p.replace(/\\/g, '/'));