From 7c01910e545165cae56353044014ca03d479e646 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Fri, 2 Oct 2026 05:00:47 +0000 Subject: [PATCH] fix(cfml): tag-based calls, wrapped functions and are indexed (#2091) (#2296) * fix(cfml): tag-based calls, wrapped functions and are indexed (#2091) Tag-based CFML lost almost all of its call edges. The tag walk sent only and bodies through call extraction, although the cfml grammar parses the expressions in , /, 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 , 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. is read the same way. A nested in a generic container tag at component or template scope ( in Application.cfc, ) was skipped because only direct children were examined; it is now extracted (never through an ERROR node, where a lost would misfile methods as top-level functions). , 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 * docs(changelog): tag-based CFML calls (#2091) Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/cfml-tag-calls.test.ts | 85 ++++++++ __tests__/extraction.test.ts | 156 +++++++++++++ src/extraction/cfml-extractor.ts | 363 ++++++++++++++++++++++++++----- src/resolution/name-matcher.ts | 14 ++ 5 files changed, 560 insertions(+), 59 deletions(-) create mode 100644 __tests__/cfml-tag-calls.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b83668890..01a871b16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ``, ``/``, ``, `` and `#…#` expressions. Before, only `` and `` code was read, so callers and impact found almost nothing in tag-based components. Functions wrapped in tags like `` or `` are now indexed too, and a `` 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 diff --git a/__tests__/cfml-tag-calls.test.ts b/__tests__/cfml-tag-calls.test.ts new file mode 100644 index 000000000..f2741ed2d --- /dev/null +++ b/__tests__/cfml-tag-calls.test.ts @@ -0,0 +1,85 @@ +/** + * Tag-based CFML end to end (#2091). Most calls in a tag-based component sit + * in ``/``/`` and `#hash#` expressions, not in + * ``; on a 6,000-file tag-based codebase only 6.4% of methods had a + * caller in the graph. Functions wrapped in `` (the + * Application.cfc shape) and `` 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 = { + 'model/Svc.cfc': ` +\t +\t\t +\t\t +\t\t +\t +\t +\t +\t + +`, + 'Application.cfc': ` +\t +\t\t +\t\t\t +\t\t +\t\t +\t + +`, + 'model/ISearchable.cfc': ` +\t + +`, + 'model/Catalog.cfc': ` +\t + +`, + }; + 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 , and 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 and the calls it makes', () => { + expect(edgesFrom('Application.cfc', 'calls')).toEqual(['Application::onRequestStart -> Application::loadConfig']); + }); + + it('resolves to a 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']); + }); +}); diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 7fad827ad..c6a522dec 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -11099,6 +11099,162 @@ import foo.cfm; expect(result.nodes.find((n) => n.name === 'innerHelper')?.kind).toBe('function'); }); }); + + describe('Calls in tag expressions outside / (#2091)', () => { + const callsFrom = (result: ReturnType, 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 , , and , attributed to the enclosing method', () => { + const code = ` +\t +\t\t +\t\t +\t\t +\t\t +\t\t +\t +\t + +`; + 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 bodies', () => { + const code = ` + + + + #fmt(y)# and #variables.mailer.send()# + + SELECT #col()# FROM t + + + +`; + 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', + ]); + // `` 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 = `\n\n\n\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 = `\n#renderList(items)#\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 = ` + + #last()# + + +`; + 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 attribute as an expression', () => { + const code = `\n\n\n\n\n\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 \n

also(not)

\n
\n\n`; + const result = extractFromSource('Page.cfc', code); + expect(result.unresolvedReferences.filter((r) => r.referenceKind === 'calls')).toEqual([]); + }); + }); + + describe(' inside a generic container tag (#2091)', () => { + it('should extract a method nested in (the Application.cfc shape)', () => { + const code = ` +\t +\t\t +\t\t\t +\t\t +\t + +`; + 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 in a template', () => { + const code = `\n\n\n`; + const result = extractFromSource('lib.cfm', code); + expect(result.nodes.find((n) => n.name === 'helper')?.kind).toBe('function'); + }); + }); + + describe(' (#2091)', () => { + const code = ` +\t +\t\t +\t +\t + +`; + + 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', () => { diff --git a/src/extraction/cfml-extractor.ts b/src/extraction/cfml-extractor.ts index 8395940c1..efd2ea6ea 100644 --- a/src/extraction/cfml-extractor.ts +++ b/src/extraction/cfml-extractor.ts @@ -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: ``, ``, ``, ``, and `#…#`. */ +const TAG_EXPRESSION_PARENTS: ReadonlySet = 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 = 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 `` tag bodies the same way. + * grammar, delegating any `` tag bodies the same way — and the + * expressions written in tags themselves (``, ``, `#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 ``) 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; } + /** + * `...` (#2091). Unlike + * `` (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 `` signatures — is ordinary nested children. Named like + * a component (the file name, unless a `name` attribute says otherwise), so + * `` 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, + }); + } + } + /** * `...`. * `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 / bodies nested inside this function, at - // any depth (e.g. inside // control-flow tags). + // Walk the body, at any depth (e.g. inside // + // control-flow tags): its / 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 ``/`` nested inside - * ``/``/`` control-flow tags, which (unlike - * ``'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 `` 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 `` declares a + * function of that scope; `parentClassName` is set at component/interface + * scope, where those functions are methods. + * + * - ``/`` bodies go to their own grammars (see + * delegateScriptTag / delegateQueryTag). + * - A `` is extracted when reached at declaration scope, at any + * depth: `` around Application.cfc's handlers + * (#2091) or `` 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 `` the parser lost (behind an + * unclosed ``), whose methods must not be misfiled as + * top-level functions. Inside a function body a `` would be + * its own scope; CFML rejects that, so it is skipped. + * - An expression in tag position — the value of ``, the condition + * of ``/``, the operand of ``, 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; + } + // `` — 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 ``/`` + * nested inside ``/``/`` control-flow tags, which + * (unlike ``'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 `` 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 `` 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 (`` + * 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 (`` → `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; } diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 701c0ea03..e0b4ad28b 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -4644,6 +4644,8 @@ function cfmlComponentFiles(dotted: string, from: string, context: ResolutionCon return files; } +const NO_RECEIVER_LINES = new WeakMap>(); + /** * 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);