fix(cfml): tag-based calls, wrapped functions and <cfinterface> are indexed (#2091) (#2296)

* fix(cfml): tag-based calls, wrapped functions and <cfinterface> are indexed (#2091)

Tag-based CFML lost almost all of its call edges. The tag walk sent only
<cfscript> and <cfquery> bodies through call extraction, although the cfml
grammar parses the expressions in <cfset>, <cfif>/<cfelseif>, <cfreturn>
and #hash# (output, strings, attributes) with the same rules as cfscript.
The walk now records each such expression with the scope that owns it
(the enclosing <cffunction>, else the component, else the file) and reads
them all through the existing cfscript extraction in one parse per file:
the expressions are copied out on their original lines, each ended with a
`;`, and every reference is handed back to its expression's scope with its
column restored. <cfloop condition="..."> is read the same way.

A <cffunction> nested in a generic container tag at component or template
scope (<cfprocessingdirective> in Application.cfc, <cfsilent>) was skipped
because only direct children were examined; it is now extracted (never
through an ERROR node, where a lost <cfcomponent> would misfile methods as
top-level functions). <cfinterface>, a generic tag to the grammar, now
yields an interface node with its functions as methods and an `extends`
ref per comma-separated parent, so `implements` resolves to it.

The tag-based parse tree is now freed after the walk.

Resolution: the CFML/Ruby/C# bare-call scope filters evaluated the
receiver-on-line check once per same-named candidate though only the ref
decides it. With tag calls extracted, that made Mach-II's resolution ~8x
slower; the check is now memoized per ref (edges byte-identical).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(changelog): tag-based CFML calls (#2091)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-02 05:00:47 +00:00
committed by GitHub
parent 2ff7a99c0e
commit 7c01910e54
5 changed files with 560 additions and 59 deletions
+1
View File
@@ -170,6 +170,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- In Python, a method passed as a value through a module-level variable that a setup function assigns, like `settings.docStoreConn.delete_payload_fields` after `global docStoreConn; docStoreConn = QdrantConnection()`, now links to that class's method. When several backend classes can be assigned, it links to the declaration they all inherit. Thanks @JosefAschauer. (#2074)
- In Python, an import split over several lines in parentheses, `from x import (A, B)` written one name per line, is now read in full. Before, its names were invisible to resolution, so Django REST Framework's `serializers.CharField()` and netbox's model mixins linked to the wrong class or to nothing. Thanks @JosefAschauer.
- Indexing a large C or C++ project no longer runs out of memory partway through "Resolving refs". Headers whose include guard is written as `#define X_H 1`, as in OpenSceneGraph and osgEarth, or that are included under a build flag codegraph cannot know, used to be re-read on every include path, which grows exponentially with the depth of the include tree. Each is now read once per include state, and a hard limit stops the check on any include tree that is still too large. Thanks @danusha2345, and @coolhitmanleon for the report. (#2127)
- In tag-based CFML, calls written in tags are now linked: in `<cfset>`, `<cfif>`/`<cfelseif>`, `<cfreturn>`, `<cfloop condition>` and `#…#` expressions. Before, only `<cfscript>` and `<cfquery>` code was read, so callers and impact found almost nothing in tag-based components. Functions wrapped in tags like `<cfprocessingdirective>` or `<cfsilent>` are now indexed too, and a `<cfinterface>` is indexed as an interface that `implements` links to. Re-index CFML projects after upgrading. Thanks @HarryMuc for the report. (#2091)
## [1.6.1] - 2026-09-29
+85
View File
@@ -0,0 +1,85 @@
/**
* Tag-based CFML end to end (#2091). Most calls in a tag-based component sit
* in `<cfset>`/`<cfif>`/`<cfreturn>` and `#hash#` expressions, not in
* `<cfscript>`; on a 6,000-file tag-based codebase only 6.4% of methods had a
* caller in the graph. Functions wrapped in `<cfprocessingdirective>` (the
* Application.cfc shape) and `<cfinterface>` components were missing as well,
* so their callers and implementers had nothing to land on.
*/
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
let root = '';
let cg: CodeGraph;
beforeAll(async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-cfml-tag-calls-'));
const files: Record<string, string> = {
'model/Svc.cfc': `<cfcomponent>
\t<cffunction name="a">
\t\t<cfset x = b(1)>
\t\t<cfif c(2)></cfif>
\t\t<cfreturn d()>
\t</cffunction>
\t<cffunction name="b"></cffunction>
\t<cffunction name="c"></cffunction>
\t<cffunction name="d"></cffunction>
</cfcomponent>
`,
'Application.cfc': `<cfcomponent>
\t<cfprocessingdirective suppresswhitespace="true">
\t\t<cffunction name="onRequestStart">
\t\t\t<cfset loadConfig()>
\t\t</cffunction>
\t\t<cffunction name="loadConfig"></cffunction>
\t</cfprocessingdirective>
</cfcomponent>
`,
'model/ISearchable.cfc': `<cfinterface>
\t<cffunction name="search" access="public"></cffunction>
</cfinterface>
`,
'model/Catalog.cfc': `<cfcomponent implements="ISearchable">
\t<cffunction name="search" access="public"></cffunction>
</cfcomponent>
`,
};
for (const [rel, content] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
fs.writeFileSync(path.join(root, rel), content);
}
cg = await CodeGraph.init(root, { index: true });
});
afterAll(() => {
cg?.close();
if (root) fs.rmSync(root, { recursive: true, force: true });
});
const edgesFrom = (file: string, kind: string): string[] => {
const ids = cg.getNodesInFile(file).map((n) => n.id);
return cg
.getOutgoingEdgesFrom(ids)
.filter((e) => e.kind === kind)
.map((e) => `${cg.getNode(e.source)!.qualifiedName} -> ${cg.getNode(e.target)!.qualifiedName}`)
.sort();
};
describe('tag-based CFML (#2091)', () => {
it('links calls written in <cfset>, <cfif> and <cfreturn> to the methods they call', () => {
expect(edgesFrom('model/Svc.cfc', 'calls')).toEqual(['Svc::a -> Svc::b', 'Svc::a -> Svc::c', 'Svc::a -> Svc::d']);
});
it('indexes a method wrapped in <cfprocessingdirective> and the calls it makes', () => {
expect(edgesFrom('Application.cfc', 'calls')).toEqual(['Application::onRequestStart -> Application::loadConfig']);
});
it('resolves <cfcomponent implements="…"> to a <cfinterface> component', () => {
const iface = cg.getNodesInFile('model/ISearchable.cfc').find((n) => n.kind === 'interface');
expect(iface?.name).toBe('ISearchable');
expect(edgesFrom('model/Catalog.cfc', 'implements')).toEqual(['model/Catalog.cfc::Catalog -> model/ISearchable.cfc::ISearchable']);
});
});
+156
View File
@@ -11099,6 +11099,162 @@ import foo.cfm;
expect(result.nodes.find((n) => n.name === 'innerHelper')?.kind).toBe('function');
});
});
describe('Calls in tag expressions outside <cfscript>/<cfquery> (#2091)', () => {
const callsFrom = (result: ReturnType<typeof extractFromSource>, fromId: string | undefined) =>
result.unresolvedReferences
.filter((r) => r.fromNodeId === fromId && (r.referenceKind === 'calls' || r.referenceKind === 'instantiates'))
.map((r) => `${r.referenceKind === 'instantiates' ? 'new ' : ''}${r.referenceName}@${r.line}`)
.sort();
it('should extract calls in <cfset>, <cfif>, <cfelseif> and <cfreturn>, attributed to the enclosing method', () => {
const code = `<cfcomponent>
\t<cffunction name="a">
\t\t<cfset x = b(1)>
\t\t<cfif c(2)>
\t\t<cfelseif e(3)>
\t\t</cfif>
\t\t<cfreturn d()>
\t</cffunction>
\t<cffunction name="b"></cffunction>
</cfcomponent>
`;
const result = extractFromSource('Svc.cfc', code);
const a = result.nodes.find((n) => n.kind === 'method' && n.name === 'a');
expect(a).toBeDefined();
expect(callsFrom(result, a?.id)).toEqual(['b@3', 'c@4', 'd@7', 'e@5']);
});
it('should extract #hash# expressions in output, strings and tag attributes — once each, alongside <cfquery> bodies', () => {
const code = `<cfcomponent>
<cffunction name="render">
<cfset var y = svc.load(1)>
<cfset local.msg = "Hi #userName()#">
<cfoutput>#fmt(y)# and #variables.mailer.send()#</cfoutput>
<cfloop array="#getItems()#" index="i"></cfloop>
<cfquery name="q" datasource="#dsn()#">SELECT #col()# FROM t</cfquery>
<cfset obj = new Widget()>
</cffunction>
</cfcomponent>
`;
const result = extractFromSource('View.cfc', code);
const render = result.nodes.find((n) => n.kind === 'method' && n.name === 'render');
expect(callsFrom(result, render?.id)).toEqual([
'col@7',
'dsn@7',
'fmt@5',
'getItems@6',
'new Widget@8',
'svc.load@3',
'userName@4',
'variables.mailer.send@5',
]);
// `<cfset var y = …>` is a function local — no variable node for it.
expect(result.nodes.find((n) => n.name === 'y')).toBeUndefined();
});
it('should attribute component-scope and template-scope calls to the component and the file', () => {
const component = `<cfcomponent>\n<cfset setup()>\n<cffunction name="m"></cffunction>\n</cfcomponent>\n`;
const cfc = extractFromSource('Pseudo.cfc', component);
const cls = cfc.nodes.find((n) => n.kind === 'class');
expect(callsFrom(cfc, cls?.id)).toEqual(['setup@2']);
const template = `<cfset items = loadItems()>\n<cfoutput>#renderList(items)#</cfoutput>\n`;
const cfm = extractFromSource('index.cfm', template);
const file = cfm.nodes.find((n) => n.kind === 'file');
expect(callsFrom(cfm, file?.id)).toEqual(['loadItems@1', 'renderList@2']);
});
it("should keep each call's own line and column (several per line, and across lines)", () => {
const code = `<cfcomponent>
<cffunction name="m"><cfset a()><cfif b.c()><cfset d(e())></cfif>
<cfset total = sum(
count(), other.fetch())><cfoutput>#last()#</cfoutput>
</cffunction>
</cfcomponent>
`;
const lines = code.split('\n');
const result = extractFromSource('Pos.cfc', code);
const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls');
expect(calls.map((r) => r.referenceName).sort()).toEqual(['a', 'b.c', 'count', 'd', 'e', 'last', 'other.fetch', 'sum']);
for (const r of calls) {
expect(lines[r.line - 1]!.slice(r.column).startsWith(r.referenceName)).toBe(true);
}
});
it('should read a <cfloop condition="…"> attribute as an expression', () => {
const code = `<cfcomponent>\n<cffunction name="drain">\n<cfloop condition="hasNext()">\n</cfloop>\n</cffunction>\n</cfcomponent>\n`;
const result = extractFromSource('Queue.cfc', code);
const drain = result.nodes.find((n) => n.kind === 'method' && n.name === 'drain');
expect(callsFrom(result, drain?.id)).toEqual(['hasNext@3']);
});
it('should not read HTML <script> bodies or plain text as CFML calls', () => {
const code = `<cfcomponent>\n<cffunction name="page">\n<script>notACall();</script>\n<p>also(not)</p>\n</cffunction>\n</cfcomponent>\n`;
const result = extractFromSource('Page.cfc', code);
expect(result.unresolvedReferences.filter((r) => r.referenceKind === 'calls')).toEqual([]);
});
});
describe('<cffunction> inside a generic container tag (#2091)', () => {
it('should extract a method nested in <cfprocessingdirective> (the Application.cfc shape)', () => {
const code = `<cfcomponent>
\t<cfprocessingdirective suppresswhitespace="true">
\t\t<cffunction name="onRequestStart">
\t\t\t<cfset loadConfig()>
\t\t</cffunction>
\t</cfprocessingdirective>
</cfcomponent>
`;
const result = extractFromSource('Application.cfc', code);
const cls = result.nodes.find((n) => n.kind === 'class');
const method = result.nodes.find((n) => n.kind === 'method' && n.name === 'onRequestStart');
expect(method).toBeDefined();
expect(method?.qualifiedName).toBe('Application::onRequestStart');
expect(result.edges.some((e) => e.kind === 'contains' && e.source === cls?.id && e.target === method?.id)).toBe(true);
const call = result.unresolvedReferences.find((r) => r.referenceName === 'loadConfig');
expect(call?.fromNodeId).toBe(method?.id);
});
it('should extract a top-level function nested in <cfsilent> in a template', () => {
const code = `<cfsilent>\n<cffunction name="helper"><cfreturn 1></cffunction>\n</cfsilent>\n`;
const result = extractFromSource('lib.cfm', code);
expect(result.nodes.find((n) => n.name === 'helper')?.kind).toBe('function');
});
});
describe('<cfinterface> (#2091)', () => {
const code = `<cfinterface extends="IBase, IOther">
\t<cffunction name="search" access="public" returntype="any">
\t\t<cfargument name="q" type="string">
\t</cffunction>
\t<cffunction name="count"></cffunction>
</cfinterface>
`;
it('should extract an interface node named from the file, with its functions as methods', () => {
const result = extractFromSource('ISearchable.cfc', code);
const iface = result.nodes.find((n) => n.kind === 'interface');
expect(iface?.name).toBe('ISearchable');
expect(iface?.startLine).toBe(1);
expect(iface?.endLine).toBe(6);
expect(result.nodes.filter((n) => n.kind === 'class')).toHaveLength(0);
const methods = result.nodes.filter((n) => n.kind === 'method');
expect(methods.map((m) => m.qualifiedName).sort()).toEqual(['ISearchable::count', 'ISearchable::search']);
for (const m of methods) {
expect(result.edges.some((e) => e.kind === 'contains' && e.source === iface?.id && e.target === m.id)).toBe(true);
}
expect(methods.find((m) => m.name === 'search')?.returnType).toBe('any');
});
it('should extract each interface it extends', () => {
const result = extractFromSource('ISearchable.cfc', code);
const iface = result.nodes.find((n) => n.kind === 'interface');
const ext = result.unresolvedReferences.filter((r) => r.referenceKind === 'extends');
expect(ext.map((r) => r.referenceName)).toEqual(['IBase', 'IOther']);
expect(ext.every((r) => r.fromNodeId === iface?.id)).toBe(true);
});
});
});
describe('COBOL Extraction', () => {
+304 -59
View File
@@ -4,6 +4,55 @@ import { generateNodeId, NodeIdAllocator } from './tree-sitter-helpers';
import { TreeSitterExtractor } from './tree-sitter';
import { getParser } from './grammars';
/** Tags whose own children include an expression: `<cfset …>`, `<cfif …>`, `<cfelseif …>`, `<cfreturn …>`, and `#…#`. */
const TAG_EXPRESSION_PARENTS: ReadonlySet<string> = new Set([
'cf_set_tag',
'cf_if_tag',
'cf_elseif_tag',
'cf_return_tag',
'hash_expression',
]);
/**
* The expression node types (shared with the cfscript grammar) that can
* contain a call. A positive list, so a tag's markup — body content, its
* `var` keyword, an ERROR — is never mistaken for an expression; a bare
* identifier or literal can't call anything and is skipped.
*/
const TAG_EXPRESSION_TYPES: ReadonlySet<string> = new Set([
'call_expression',
'member_expression',
'subscript_expression',
'new_expression',
'assignment_expression',
'augmented_assignment_expression',
'binary_expression',
'unary_expression',
'update_expression',
'ternary_expression',
'elvis_expression',
'parenthesized_expression',
'sequence_expression',
'string',
'array',
'object',
'ordered_struct',
'function_expression',
'arrow_function',
]);
/** An expression in tag markup and the scope (function, component or file node) that owns its calls. */
interface TagExpression {
startIndex: number;
endIndex: number;
/** Where it starts in the file — the same line in the source extractTagExpressions synthesizes. */
row: number;
column: number;
/** Its start column in the synthesized source. */
textColumn: number;
scopeId: string;
}
/**
* CfmlExtractor - Extracts code relationships from CFML source (.cfc/.cfm).
*
@@ -15,7 +64,9 @@ import { getParser } from './grammars';
* raw AST, so this extractor replicates it: a file whose first real token
* isn't `<` is delegated wholesale to the cfscript grammar (the dominant
* modern style); otherwise the file is walked tag-by-tag with the cfml
* grammar, delegating any `<cfscript>` tag bodies the same way.
* grammar, delegating any `<cfscript>` tag bodies the same way — and the
* expressions written in tags themselves (`<cfset>`, `<cfif>`, `#hash#`, …)
* through the same cfscript extraction (see extractTagExpressions).
*/
export class CfmlExtractor {
private filePath: string;
@@ -26,6 +77,7 @@ export class CfmlExtractor {
private edges: Edge[] = [];
private unresolvedReferences: UnresolvedReference[] = [];
private errors: ExtractionError[] = [];
private tagExpressions: TagExpression[] = [];
/** `language` is the file's detected language — `'cfml'` for `.cfc`/`.cfm`, `'cfscript'` for `.cfs`. Both dialect-switch internally; this only controls the language tag stamped onto emitted nodes/refs. */
constructor(filePath: string, source: string, language: Language = 'cfml') {
@@ -113,8 +165,14 @@ export class CfmlExtractor {
return;
}
const fileNode = this.createFileNode();
this.walkProgram(tree.rootNode, fileNode.id);
try {
const fileNode = this.createFileNode();
this.walkProgram(tree.rootNode, fileNode.id);
} finally {
// Trees hold wasm heap memory V8's GC never sees — free it per file.
tree.delete();
}
this.extractTagExpressions();
}
/** Build the file's own `kind:'file'` node, spanning the whole source. Tag-based files need this explicitly — unlike `extractBareScript` (which delegates the whole file to `TreeSitterExtractor` and inherits its file node), `extractTagBased` walks the tree itself and has no other source of one. */
@@ -151,17 +209,11 @@ export class CfmlExtractor {
if (child.type === 'cf_component_open_tag') {
child = this.extractComponent(child, fileNodeId).nextSibling;
continue;
} else if (child.type === 'cf_function_tag') {
// A cffunction outside any cfcomponent wrapper (rare, but legal in a
// .cfm template) — extract as a top-level function, contained by the file.
this.extractFunctionTag(child, undefined, fileNodeId);
} else if (child.type === 'cf_script_tag') {
this.delegateScriptTag(child, fileNodeId);
} else if (child.type === 'cf_query_tag') {
this.delegateQueryTag(child, fileNodeId);
} else {
this.delegateNestedTags(child, fileNodeId);
}
// Template scope: a cffunction outside any cfcomponent wrapper (rare,
// but legal in a .cfm template) is a top-level function contained by
// the file, and template code's calls are the file's.
this.visitTag(child, root.type, fileNodeId, undefined, true);
child = child.nextSibling;
}
}
@@ -208,20 +260,7 @@ export class CfmlExtractor {
language: this.language,
});
}
const implementsAttr = this.tagAttr(openTag, 'implements');
if (implementsAttr) {
for (const iface of implementsAttr.split(',').map((s) => s.trim()).filter(Boolean)) {
this.unresolvedReferences.push({
fromNodeId: classNode.id,
referenceName: iface,
referenceKind: 'implements',
filePath: this.filePath,
line: openTag.startPosition.row + 1,
column: openTag.startPosition.column,
language: this.language,
});
}
}
this.pushInheritanceRefs(classNode.id, this.tagAttr(openTag, 'implements'), 'implements', openTag);
// Walk siblings between the open tag and its close tag.
let sibling = openTag.nextSibling;
@@ -231,15 +270,9 @@ export class CfmlExtractor {
lastNode = sibling;
break;
}
if (sibling.type === 'cf_function_tag') {
this.extractFunctionTag(sibling, classNode.id, classNode.id, classNode.name);
} else if (sibling.type === 'cf_script_tag') {
this.delegateScriptTag(sibling, classNode.id, classNode.name);
} else if (sibling.type === 'cf_query_tag') {
this.delegateQueryTag(sibling, classNode.id);
} else {
this.delegateNestedTags(sibling, classNode.id, classNode.name);
}
// Component scope: cffunctions are its methods, pseudo-constructor
// code's calls (a component-level `<cfset init()>`) are the component's.
this.visitTag(sibling, openTag.parent?.type ?? '', classNode.id, classNode.name, true);
lastNode = sibling;
sibling = sibling.nextSibling;
}
@@ -247,6 +280,54 @@ export class CfmlExtractor {
return lastNode;
}
/**
* `<cfinterface extends="IBase,IOther">...</cfinterface>` (#2091). Unlike
* `<cfcomponent>` (see the implicit-end-tag note on `extractComponent`) the
* grammar has no dedicated rule for it: it is a generic `cf_tag` whose body
* — the `<cffunction>` signatures — is ordinary nested children. Named like
* a component (the file name, unless a `name` attribute says otherwise), so
* `<cfcomponent implements="ISearchable">` resolves to it. An interface may
* extend several interfaces, comma-separated.
*/
private extractInterfaceTag(tag: SyntaxNode, containerId: string): void {
const startTag = this.cfStartTag(tag) ?? tag;
const name = this.tagAttr(startTag, 'name') ?? this.componentNameFromPath();
const id = this.nodeIds.generate(this.filePath, 'interface', name, tag.startPosition.row + 1, tag.startPosition.column);
this.nodes.push({
id,
kind: 'interface',
name,
qualifiedName: `${this.filePath}::${name}`,
filePath: this.filePath,
language: this.language,
startLine: tag.startPosition.row + 1,
endLine: tag.endPosition.row + 1,
startColumn: tag.startPosition.column,
endColumn: tag.endPosition.column,
isExported: true,
updatedAt: Date.now(),
});
this.edges.push({ source: containerId, target: id, kind: 'contains' });
this.pushInheritanceRefs(id, this.tagAttr(startTag, 'extends'), 'extends', startTag);
this.delegateNestedTags(tag, id, name, true);
}
/** One unresolved `extends`/`implements` ref per name in a comma-separated tag attribute. */
private pushInheritanceRefs(fromNodeId: string, list: string | undefined, kind: 'extends' | 'implements', tag: SyntaxNode): void {
if (!list) return;
for (const name of list.split(',').map((s) => s.trim()).filter(Boolean)) {
this.unresolvedReferences.push({
fromNodeId,
referenceName: name,
referenceKind: kind,
filePath: this.filePath,
line: tag.startPosition.row + 1,
column: tag.startPosition.column,
language: this.language,
});
}
}
/**
* `<cffunction name="..." access="..." returntype="...">...</cffunction>`.
* `parentClassId` decides `method` vs top-level `function`; `containerId` is
@@ -290,36 +371,190 @@ export class CfmlExtractor {
this.edges.push({ source: containerId, target: fnNode.id, kind: 'contains' });
}
// Delegate any <cfscript>/<cfquery> bodies nested inside this function, at
// any depth (e.g. inside <cfif>/<cfloop>/<cftry> control-flow tags).
// Walk the body, at any depth (e.g. inside <cfif>/<cfloop>/<cftry>
// control-flow tags): its <cfscript>/<cfquery> bodies and tag
// expressions are this function's.
this.delegateNestedTags(tag, fnNode.id);
}
/**
* Recursively delegates any `cf_script_tag`/`cf_query_tag` found within
* `node`'s subtree — e.g. a `<cfscript>`/`<cfquery>` nested inside
* `<cfif>`/`<cfloop>`/`<cftry>` control-flow tags, which (unlike
* `<cfcomponent>`'s body — see the implicit-end-tag note on `extractComponent`)
* ARE normal children, just possibly several levels deep, so a direct-children
* check misses them. Does not descend into a nested `cf_function_tag` — that
* has its own scope and is walked separately. `parentClassName` rides along
* so a `<cfscript>` at component scope classifies its functions as methods
* scoped under the component.
* Visit one node of tag markup. `containerId` is the scope the node sits in
* — the function whose body it is, else the component, else the file — and
* owns whatever calls it makes. `declScope` is true outside function bodies
* (component/interface/template scope), where a `<cffunction>` declares a
* function of that scope; `parentClassName` is set at component/interface
* scope, where those functions are methods.
*
* - `<cfscript>`/`<cfquery>` bodies go to their own grammars (see
* delegateScriptTag / delegateQueryTag).
* - A `<cffunction>` is extracted when reached at declaration scope, at any
* depth: `<cfprocessingdirective>` around Application.cfc's handlers
* (#2091) or `<cfsilent>` around a template's helpers doesn't make them
* any less the scope's functions. Never through an ERROR node — at file
* scope that is typically a `<cfcomponent>` the parser lost (behind an
* unclosed `<cfsetting>`), whose methods must not be misfiled as
* top-level functions. Inside a function body a `<cffunction>` would be
* its own scope; CFML rejects that, so it is skipped.
* - An expression in tag position — the value of `<cfset>`, the condition
* of `<cfif>`/`<cfelseif>`, the operand of `<cfreturn>`, a `#hash#`
* anywhere in markup, attributes and strings included — is recorded for
* extractTagExpressions. The grammar parses these structurally (the same
* expression rules as cfscript), so `parentType` (the node's parent) is
* enough to tell the expression from the tag around it.
*/
private delegateNestedTags(node: SyntaxNode, containerId: string | undefined, parentClassName?: string): void {
private visitTag(node: SyntaxNode, parentType: string, containerId: string, parentClassName: string | undefined, declScope: boolean): void {
switch (node.type) {
case 'cf_script_tag':
this.delegateScriptTag(node, containerId, parentClassName);
return;
case 'cf_query_tag':
// The SQL body is opaque to this grammar; the walk below only reaches
// the tag's attributes (`datasource="#dsn()#"`), never the body twice.
this.delegateQueryTag(node, containerId);
break;
case 'cf_function_tag':
if (declScope) this.extractFunctionTag(node, parentClassName ? containerId : undefined, containerId, parentClassName);
return;
case 'cf_tag': {
const name = this.cfTagName(node);
if (name === 'interface' && declScope && !parentClassName) {
this.extractInterfaceTag(node, containerId);
return;
}
// `<cfloop condition="hasNext()">` — the one common tag attribute whose
// plain-text value is a CFML expression rather than a literal.
if (name === 'loop') {
const condition = this.tagAttrValueNode(this.cfStartTag(node) ?? node, 'condition');
if (condition) this.addTagExpression(condition, containerId);
}
break;
}
default:
if (TAG_EXPRESSION_PARENTS.has(parentType) && TAG_EXPRESSION_TYPES.has(node.type)) {
this.addTagExpression(node, containerId);
return;
}
}
this.delegateNestedTags(node, containerId, parentClassName, declScope && node.type !== 'ERROR');
}
/**
* Visit `node`'s children (see visitTag) — e.g. a `<cfscript>`/`<cfquery>`
* nested inside `<cfif>`/`<cfloop>`/`<cftry>` control-flow tags, which
* (unlike `<cfcomponent>`'s body — see the implicit-end-tag note on
* `extractComponent`) ARE normal children, just possibly several levels
* deep, so a direct-children check misses them. `parentClassName` rides
* along so a `<cfscript>` at component scope classifies its functions as
* methods scoped under the component.
*/
private delegateNestedTags(node: SyntaxNode, containerId: string, parentClassName?: string, declScope = false): void {
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (!child) continue;
if (child.type === 'cf_script_tag') {
this.delegateScriptTag(child, containerId, parentClassName);
} else if (child.type === 'cf_query_tag') {
this.delegateQueryTag(child, containerId);
} else if (child.type === 'cf_function_tag') {
continue;
if (child) this.visitTag(child, node.type, containerId, parentClassName, declScope);
}
}
/** Record an expression in tag markup, owned by `scopeId`, for extractTagExpressions. */
private addTagExpression(node: SyntaxNode, scopeId: string): void {
if (node.endIndex <= node.startIndex) return;
this.tagExpressions.push({
startIndex: node.startIndex,
endIndex: node.endIndex,
row: node.startPosition.row,
column: node.startPosition.column,
textColumn: 0,
scopeId,
});
}
/**
* Extract calls from the expressions visitTag recorded, through the same
* cfscript extraction a `<cfscript>` body gets — one parse per file, not
* one per expression: the expressions are copied out in order, each on its
* own line (the line breaks between them are kept, so every line number is
* the file's) and ended with a `;`, and the result is read with the
* cfscript grammar. Each reference is handed back to the scope whose
* expression contains it, its column shifted back to the file's. Only
* references are kept: an expression declares nothing (`<cfset var x = …>`
* is a function local, and its `var` isn't copied), so a closure written in
* one is part of its scope's code.
*/
private extractTagExpressions(): void {
const exprs = this.tagExpressions;
if (exprs.length === 0) return;
exprs.sort((a, b) => a.startIndex - b.startIndex);
const parts: string[] = [];
let pos = 0;
let column = 0; // column in the synthesized source
for (const expr of exprs) {
if (pos > 0) {
parts.push(';');
column++;
}
let breaks = 0;
for (let at = this.source.indexOf('\n', pos); at !== -1 && at < expr.startIndex; at = this.source.indexOf('\n', at + 1)) breaks++;
if (breaks > 0) {
parts.push('\n'.repeat(breaks));
column = 0;
}
expr.textColumn = column;
const text = this.source.slice(expr.startIndex, expr.endIndex);
parts.push(text);
const lastBreak = text.lastIndexOf('\n');
column = lastBreak === -1 ? column + text.length : text.length - lastBreak - 1;
pos = expr.endIndex;
}
const result = new TreeSitterExtractor(this.filePath, parts.join(''), 'cfscript').extract();
for (const ref of result.unresolvedReferences) {
const expr = this.tagExpressionAt(ref.line - 1, ref.column);
// Only an expression's first line moved; its later lines are verbatim.
if (ref.line - 1 === expr.row) ref.column += expr.column - expr.textColumn;
ref.fromNodeId = expr.scopeId;
ref.filePath = this.filePath;
ref.language = this.language;
this.unresolvedReferences.push(ref);
}
// A broken expression is routine in hand-written markup and the file's
// symbols came from the tag walk, so only a genuine failure is reported —
// not the extractor's "no symbols" warning, which an expressions-only
// source always earns.
for (const error of result.errors) {
if (error.severity === 'error') this.errors.push(error);
}
}
/** The recorded expression containing a synthesized-source position: the last one starting at or before it. */
private tagExpressionAt(row: number, column: number): TagExpression {
const exprs = this.tagExpressions;
let lo = 0;
let hi = exprs.length - 1;
let found = 0;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
const e = exprs[mid]!;
if (e.row < row || (e.row === row && e.textColumn <= column)) {
found = mid;
lo = mid + 1;
} else {
this.delegateNestedTags(child, containerId, parentClassName);
hi = mid - 1;
}
}
return exprs[found]!;
}
/** A generic `cf_tag`'s start tag — where its name and attributes live. */
private cfStartTag(tag: SyntaxNode): SyntaxNode | undefined {
return tag.namedChildren.find(
(c: SyntaxNode) => c.type === 'cf_start_tag' || c.type === 'cf_start_tag_with_selfclose'
);
}
/** A generic `cf_tag`'s name, lowercased, without the `cf` prefix (`<cfLoop>` → `loop`). */
private cfTagName(tag: SyntaxNode): string | undefined {
const nameNode = this.cfStartTag(tag)?.namedChildren.find((c: SyntaxNode) => c.type === 'cf_tag_name');
return nameNode ? this.source.substring(nameNode.startIndex, nameNode.endIndex).toLowerCase() : undefined;
}
/**
@@ -445,6 +680,18 @@ export class CfmlExtractor {
/** Read a `cf_attribute`'s value by name from a tag node's direct `cf_attribute`/`cf_tag_attributes` children. */
private tagAttr(tag: SyntaxNode, attrName: string): string | undefined {
const valueNode = this.tagAttrValueNode(tag, attrName);
if (valueNode === undefined) return undefined;
if (!valueNode) return '';
return this.source.substring(valueNode.startIndex, valueNode.endIndex);
}
/**
* 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).
*/
private tagAttrValueNode(tag: SyntaxNode, attrName: string): SyntaxNode | null | undefined {
const attrs: SyntaxNode[] = [];
for (let i = 0; i < tag.namedChildCount; i++) {
const child = tag.namedChild(i);
@@ -467,9 +714,7 @@ export class CfmlExtractor {
const valueWrapper = attr.namedChildren.find(
(c: SyntaxNode) => c.type === 'quoted_cf_attribute_value' || c.type === 'cf_attribute_value'
);
const valueNode = valueWrapper?.namedChildren.find((c: SyntaxNode) => c.type === 'attribute_value');
if (!valueNode) return '';
return this.source.substring(valueNode.startIndex, valueNode.endIndex);
return valueWrapper?.namedChildren.find((c: SyntaxNode) => c.type === 'attribute_value') ?? null;
}
return undefined;
}
+14
View File
@@ -4644,6 +4644,8 @@ function cfmlComponentFiles(dotted: string, from: string, context: ResolutionCon
return files;
}
const NO_RECEIVER_LINES = new WeakMap<ResolutionContext, WeakMap<UnresolvedRef, boolean>>();
/**
* Whether a bare reference is receiver-less at its call site, name case
* aside: the name is not preceded by a `.` on its line (true when the line
@@ -4651,6 +4653,17 @@ function cfmlComponentFiles(dotted: string, from: string, context: ResolutionCon
* links of a chain (`newFuture(f).then(g)`) over bare.
*/
function hasNoReceiverOnLine(ref: UnresolvedRef, context: ResolutionContext): boolean {
// Asked once per CANDIDATE by the per-language scope filters, though only
// the ref decides it — a bare `init()` in a CFML codebase has hundreds of
// same-named methods, each re-reading the line (#2091).
let memo = NO_RECEIVER_LINES.get(context);
if (!memo) NO_RECEIVER_LINES.set(context, (memo = new WeakMap()));
let answer = memo.get(ref);
if (answer === undefined) memo.set(ref, (answer = readNoReceiverOnLine(ref, context)));
return answer;
}
function readNoReceiverOnLine(ref: UnresolvedRef, context: ResolutionContext): boolean {
const line = context.getFileLines?.(ref.filePath)?.[ref.line - 1] ?? context.readFile(ref.filePath)?.split('\n')[ref.line - 1];
if (line === undefined) return true;
const lower = line.toLowerCase();
@@ -6629,6 +6642,7 @@ export function clearNameMatcherMemos(context: ResolutionContext): void {
KOTLIN_FILE_SCOPES.delete(context);
RUBY_ANCESTRY.delete(context);
CFML_CHAINS.delete(context);
NO_RECEIVER_LINES.delete(context);
OBJC_SUPERS.delete(context);
CSHARP_SUPERS.delete(context);
CSHARP_STATIC_USINGS.delete(context);