mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-16 20:53:57 +08:00
* 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:
@@ -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
|
||||
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user