diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a3a6253..044d6c6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In VB.NET, a field or property initializer like `= Compute()` or `As New List(Of Order)` now links what it calls and creates, and so do a `Custom Event`'s `AddHandler`, `RemoveHandler` and `RaiseEvent` blocks. Re-index VB.NET and C# projects after upgrading. - In C#, a field or property initializer like `private readonly ILogger _log = LogManager.GetLogger(typeof(X));` or `public List Items { get; } = new();` now links what it calls, creates and reads, including inside a lambda, and a target-typed `new()` there counts as creating the declared type. Before, initializers were skipped, so a method or class used only from one — like a converter created in a static list — looked unused, and a method passed as a value there was credited to the whole class instead of the field or property. - In C# and VB.NET, a constant no longer stands in for a type with the same name: `new Station { … }` links to class `Station` even when another class declares `private const string Station`, `new Version(…)` no longer links to a `const string Version`, and a field like `static readonly Meter Meter` no longer points at itself. Thanks @EvanYu1980 for the report and @drakeo338. (#2337) +- In CFML, `createObject("component", "contenscms.core.queue.batchcontext")`, and the one-argument `createObject("contenscms.core.queue.batchcontext")`, now link to the component they create, found by its path the way `extends` finds one, as `new` already did. A call chained onto the new object, like `createObject(…).init()` or `new contenscms.core.queue.batchcontext().init()`, now links to that component's `init`, or to the one it inherits. In a framework's own source, a path like `wheels.JobWorker`, whose first part is the framework itself, now also finds the component at the project root, for `extends` as well. Before, `createObject` showed up as a call to a function that doesn't exist, and the chained `init` was linked to whichever component's `init` came first, or to none. Thanks @HarryMuc for the report. (#2652) - In VB.NET, a call on a variable, parameter, field or property now reaches a method of the type it is declared with — one that type inherits, or an extension method written for it — and nothing when that type comes from outside your project, instead of any project method that merely shares the name. - In VB.NET, a call to one of .NET's own methods such as `Add`, `Contains`, `Clear` or `Dispose` on a value whose type isn't known is no longer linked to a project method of that name, unless the value is named after that method's class. - In VB.NET, a type name is looked up the way VB.NET does it — through the namespaces around it, the file's and project's `Imports` (aliases included), and the caller's own project — so a class declared in several namespaces or projects no longer draws every call to whichever copy was indexed first, and two candidates nothing tells apart get no link at all. diff --git a/__tests__/cfml-create-object.test.ts b/__tests__/cfml-create-object.test.ts new file mode 100644 index 00000000..3fb1e8e2 --- /dev/null +++ b/__tests__/cfml-create-object.test.ts @@ -0,0 +1,131 @@ +/** + * CFML `createObject("component", "a.b.Batch")` (#2652). `new a.b.Batch()` + * made an `instantiates` edge; createObject, the form older code uses most, + * made none — it was a call to an engine function that never resolves. And a + * call chained onto either construction, `createObject(…).init()` or `new + * a.b.Batch().init()`, was emitted as a bare `init`, which matched whichever + * component's `init` came first. A component path resolves as `extends` + * resolves one: by its folders, from the right. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { CodeGraph } from '../src'; + +let dir = ''; +let edges: string[] = []; +let unresolved: string[] = []; + +const files: Record = { + // Other components with an `init`, which a guess by name would reach first. + 'contens/aaa/alpha.cfc': `component {\n\tfunction init() { return this; }\n\tfunction $init() { return this; }\n}\n`, + 'contens/core/mixin.cfc': `component {\n\tfunction $init() { return this; }\n}\n`, + // A framework's own source, where its mapping (`wheels`) is the project root. + 'JobWorker.cfc': `component {\n\tfunction processNext() {}\n}\n`, + 'tests/specs/JobSpec.cfc': `component { +\tfunction run() { +\t\tnew wheels.JobWorker().processNext(); +\t\tvar w = createObject("component", "wheels.JobWorker"); +\t} +} +`, + // An app's own Controller is not the framework's `wheels.Controller`. + 'app/controllers/Controller.cfc': `component {\n\tfunction init() { return this; }\n}\n`, + 'app/handlers/Main.cfc': `component {\n\tfunction index() {\n\t\tcreateObject("component", "wheels.Controller").init();\n\t}\n}\n`, + 'contens/aaa/beta.cfc': `component {\n\tfunction init() { return this; }\n}\n`, + 'contens/core/base.cfc': `component {\n\tfunction init() { return this; }\n}\n`, + 'contens/core/queue/batchcontext.cfc': ` +\t +\t + +`, + // Inherits its init. + 'contens/core/queue/jobrunner.cfc': `component extends="contenscms.core.base" {\n\tfunction start() {}\n}\n`, + 'contens/api/batch/collection.cfc': ` +\t +\t\t +\t\t +\t\t +\t + +`, + 'contens/api/batch/scriptcollection.cfc': `component { +\tfunction process() { +\t\tvar oRunner = createObject("contenscms.core.queue.jobrunner").init(); +\t\tvar oNew = new contenscms.core.queue.batchcontext().init(); +\t\tvar oMap = createObject("java", "java.util.HashMap"); +\t\tvar oBox = createObject("component", "testbox.system.TestBox").init(); +\t\tvar oDyn = createObject("component", "#variables.root#.batchcontext"); +\t\tvar oMix = new contenscms.core.mixin().$init(); +\t} +} +`, +}; + +beforeAll(async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cfml-create-object-')); + for (const [rel, body] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true }); + fs.writeFileSync(path.join(dir, rel), body); + } + const cg = await CodeGraph.init(dir, { silent: true }); + await cg.indexAll(); + const db = (cg as any).db.db; + edges = db + .prepare( + `SELECT e.kind, e.line, s.qualified_name src, t.qualified_name tgt FROM edges e + JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target + WHERE e.kind IN ('instantiates', 'calls')` + ) + .all() + .map((r: any) => `${r.src} -${r.kind}-> ${r.tgt} @${r.line}`) + .sort(); + unresolved = db + .prepare(`SELECT reference_kind k, reference_name n, line FROM unresolved_refs`) + .all() + .map((r: any) => `${r.k} ${r.n} @${r.line}`); + cg.close?.(); +}); + +afterAll(() => { + if (dir) fs.rmSync(dir, { recursive: true, force: true }); +}); + +describe('CFML createObject (#2652)', () => { + it('links createObject("component", "a.b.c") to the component it makes, in tags and in script', () => { + expect(edges).toEqual(expect.arrayContaining([ + 'collection::process -instantiates-> contens/core/queue/batchcontext.cfc::batchcontext @3', + 'collection::process -instantiates-> contens/core/queue/jobrunner.cfc::jobrunner @5', + 'scriptcollection::process -instantiates-> contens/core/queue/jobrunner.cfc::jobrunner @3', + ])); + // Only the Java object and the component named at runtime are left as calls. + expect(unresolved.filter((r) => r.startsWith('calls createObject')).sort()).toEqual(['calls createObject @5', 'calls createObject @7']); + }); + + it('links a call chained onto the construction to that component\'s method, or one it inherits', () => { + expect(edges).toEqual(expect.arrayContaining([ + 'collection::process -calls-> batchcontext::init @3', + 'scriptcollection::process -calls-> base::init @3', + 'scriptcollection::process -calls-> batchcontext::init @4', + 'scriptcollection::process -calls-> mixin::$init @8', + ])); + expect(edges.filter((e) => /-> (alpha|beta)::\$?init/.test(e))).toEqual([]); + }); + + it('makes nothing of a component outside the project, another type of object or a name built at runtime', () => { + expect(edges.filter((e) => / @[567]$/.test(e) && e.startsWith('scriptcollection::'))).toEqual([]); + }); + + it('still types a variable assigned from createObject', () => { + expect(edges).toContain('collection::process -calls-> batchcontext::run @4'); + }); + + it('finds a component at the project root by a one-folder path, and only there', () => { + expect(edges).toEqual(expect.arrayContaining([ + 'JobSpec::run -calls-> JobWorker::processNext @3', + 'JobSpec::run -instantiates-> JobWorker.cfc::JobWorker @4', + ])); + expect(edges.filter((e) => e.startsWith('Main::index'))).toEqual([]); + }); +}); diff --git a/__tests__/cfml-inheritance-resolution.test.ts b/__tests__/cfml-inheritance-resolution.test.ts index 879da7f9..94d6b09a 100644 --- a/__tests__/cfml-inheritance-resolution.test.ts +++ b/__tests__/cfml-inheritance-resolution.test.ts @@ -163,4 +163,20 @@ describe('CFML component-path inheritance resolution (#1152)', () => { const edges = await load(); expect(edges.filter((e) => e.src === 'Caller' || e.src === 'go')).toHaveLength(0); }); + + it('resolves a one-folder path to a component at the project root (a framework\'s own repo, #2652)', async () => { + // Wheels' own source: `wheels` is the mapping onto the root, so + // `wheels.Model` is Model.cfc there, with no folder to corroborate. + write('Model.cfc', `component {}\n`); + write('models/User.cfc', `component extends="wheels.Model" {}\n`); + // A longer path names folders the root component isn't in. + write('Util.cfc', `component {}\n`); + write('models/Helper.cfc', `component extends="coldbox.system.Util" {}\n`); + // An app's own Controller sits in folders the path doesn't name. + write('app/controllers/Controller.cfc', `component {}\n`); + write('app/controllers/Main.cfc', `component extends="wheels.Controller" {}\n`); + const edges = await load(); + expect(has(edges, 'User', 'Model', 'Model.cfc')).toBe(true); + expect(edges.filter((e) => e.src === 'Helper' || e.src === 'Main')).toHaveLength(0); + }); }); diff --git a/src/extraction/languages/cfscript.ts b/src/extraction/languages/cfscript.ts index 68cd21ae..fc951ad7 100644 --- a/src/extraction/languages/cfscript.ts +++ b/src/extraction/languages/cfscript.ts @@ -19,8 +19,9 @@ function cfmlVisibility(node: SyntaxNode): 'public' | 'private' | 'protected' | /** * A string literal's text when it is written out in full, or null when a - * `#…#` expression builds it at runtime (`"#dir#/x.cfm"`): such a path names - * no file the index can link. + * `#…#` expression builds it at runtime (`"#dir#/x.cfm"`, `"#dir#.Batch"`): + * such a path names no file, and such a name no component, the index can + * link. */ function literalString(node: SyntaxNode | null | undefined, source: string): string | null { if (node?.type !== 'string' || node.namedChildren.some((c: SyntaxNode) => c.type !== 'string_fragment')) return null; @@ -55,6 +56,56 @@ function cfIncludeCallTemplate(node: SyntaxNode, source: string): string | null return literalString(args.namedChildren[0], source); } +/** A component name or dotted component path (`Batch`, `contenscms.core.queue.Batch`). */ +const COMPONENT_PATH = /^[A-Za-z_][\w$-]*(?:\.[A-Za-z_][\w$-]*)*$/; + +/** + * The component `createObject("component", "a.b.Batch")` makes — or the + * one-argument `createObject("a.b.Batch")` (Lucee, ACF), or with named + * arguments, `createObject(type="component", component="a.b.Batch")` — as + * written (#2652). Null for any other call, for another type of object + * (`createObject("java", …)`), and for a name built at runtime. + */ +export function cfmlCreatedComponent(call: SyntaxNode, source: string): string | null { + if (call.type !== 'call_expression') return null; + const fn = getChildByField(call, 'function'); + if (fn?.type !== 'identifier' || getNodeText(fn, source).toLowerCase() !== 'createobject') return null; + const args = getChildByField(call, 'arguments')?.namedChildren ?? []; + let type: string | null | undefined; + let name: string | null | undefined; + if (args.length > 0 && args.every((a: SyntaxNode) => a.type === 'assignment_expression')) { + for (const arg of args) { + const key = getNodeText(getChildByField(arg, 'left') ?? arg, source).toLowerCase(); + if (key === 'type') type = literalString(getChildByField(arg, 'right'), source); + else if (key === 'component') name = literalString(getChildByField(arg, 'right'), source); + } + } else if (args.length === 1) { + name = literalString(args[0], source); + } else if (args.length >= 2) { + type = literalString(args[0], source); + name = literalString(args[1], source); + } + if (type !== undefined && type?.toLowerCase() !== 'component') return null; + return name && COMPONENT_PATH.test(name) ? name : null; +} + +/** + * The component an expression constructs, when the expression is a + * construction a call is chained onto: `new a.b.Batch()` or a component + * `createObject(…)`, parenthesized or not (#2652). + */ +export function cfmlConstructedComponent(node: SyntaxNode, source: string): string | null { + let expr: SyntaxNode | null = node; + while (expr?.type === 'parenthesized_expression') expr = expr.namedChild(0); + if (!expr) return null; + if (expr.type === 'new_expression') { + const ctor = getChildByField(expr, 'constructor'); + const name = ctor ? getNodeText(ctor, source).replace(/\s+/g, '') : ''; + return COMPONENT_PATH.test(name) ? name : null; + } + return cfmlCreatedComponent(expr, source); +} + export const cfscriptExtractor: LanguageExtractor = { functionTypes: ['function_declaration', 'function_expression', 'arrow_function'], classTypes: ['component'], diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index c0449a40..dd6a8186 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -27,7 +27,7 @@ import { appendCppMacroMethods } from './languages/cpp-macro-methods'; import { rustImplItemOwner, rustImplTypeName } from './languages/rust'; import { goAliasTypeNames, goEmbeddedTypeName, goInterfaceMethodTypeNames, goStructFieldTypeNames } from './languages/go'; import { dartMisparsedGenericCall, dartReceiverOf, isDartTypeName, pushDartTypeAliasRefs, pushDartTypeRefs } from './languages/dart'; -import { isCfIncludeCall } from './languages/cfscript'; +import { cfmlConstructedComponent, cfmlCreatedComponent, isCfIncludeCall } from './languages/cfscript'; import { LiquidExtractor } from './liquid-extractor'; import { RazorExtractor } from './razor-extractor'; import { SvelteExtractor } from './svelte-extractor'; @@ -5174,6 +5174,24 @@ export class TreeSitterExtractor { return; } + // CFML `createObject("component", "a.b.Batch")` makes an instance of the + // component, as `new a.b.Batch()` does (#2652): an instantiation of it, + // under the path as written, which the resolver reads as `extends`' + // component paths. Not a call: createObject is the engine's own function. + if (this.language === 'cfscript') { + const created = cfmlCreatedComponent(node, this.source); + if (created) { + this.unresolvedReferences.push({ + fromNodeId: callerId, + referenceName: created, + referenceKind: 'instantiates', + line: node.startPosition.row + 1, + column: node.startPosition.column, + }); + return; + } + } + // VB.NET: `foo(args)` is syntactically ambiguous between a call and an // index read, so the grammar parses non-empty parens as // array_access_expression (field `array`, not `function`) — even Roslyn @@ -5956,6 +5974,7 @@ export class TreeSitterExtractor { return; } const SKIP_RECEIVERS = new Set(['self', 'this', 'cls', 'super']); + let constructed: string | null = null; if (receiver && (receiver.type === 'identifier' || receiver.type === 'simple_identifier' || receiver.type === 'field_identifier')) { const receiverName = getNodeText(receiver, this.source); if (!SKIP_RECEIVERS.has(receiverName)) { @@ -6079,6 +6098,14 @@ export class TreeSitterExtractor { // (`!Settings.Defaults.isOn()`). Mirrored in the kernel's // extract_call (swift.rs). calleeName = `${swiftUnwrappedText(receiver, this.source).replace(/\s+/g, '')}.${methodName}`; + } else if (this.language === 'cfscript' && receiver && (constructed = cfmlConstructedComponent(receiver, this.source))) { + // CFML call on the component the receiver constructs — + // `createObject("component", "a.b.Batch").init()`, `new + // a.b.Batch().init()` (#2652). Encode `().`: + // the resolver finds the component as `new`/`extends` do and the + // method on it, or nothing. The bare `init` this used to emit + // matched whichever component's `init` came first. + calleeName = `${constructed}().${methodName}`; } else if ( this.language === 'cfscript' && receiver && diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 536f8689..b95b2660 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -97,6 +97,9 @@ const CHAIN_SHAPE = /^(.+)\(\)\.(\w+)$/; /** PHP `$this->prop->method()` encoded as `this->prop.method` — no `()`, so CHAIN_SHAPE misses it. */ const PHP_PROP_SHAPE = /^this->\w+\.\w+$/; +/** A CFML call on a construction, `a.b.Batch().init` (#2652); CFML names may hold `$` (`$init`). */ +const CFML_CONSTRUCTED_CALL = /^([A-Za-z_$][\w$.-]*)\(\)\.([A-Za-z_$][\w$]*)$/; + /** * How many files that import a changed module by default a sync reads the * edges of, when the module forwards its default from another file, to see @@ -1567,6 +1570,15 @@ export class ReferenceResolver { ) { return this.resolveCfmlComponentPath(ref); } + // The component a CFML `createObject("component", "a.b.Batch")` makes, + // named by its component path as `extends` names one (#2652), and a call + // chained onto a construction — `createObject(…).init()`, `new + // a.b.Batch().init()`, encoded `a.b.Batch().init` — which is a method of + // that component or of nothing: no name match may guess past it. + if (ref.language === 'cfml' || ref.language === 'cfscript') { + if (ref.referenceKind === 'instantiates' && ref.referenceName.includes('.')) return this.resolveCfmlComponentPath(ref); + if (ref.referenceKind === 'calls' && CFML_CONSTRUCTED_CALL.test(ref.referenceName)) return this.resolveCfmlConstructedCall(ref); + } // A CFML include names a template by its path from the including file's // directory, or from the web root (#2651): ` (n.kind === 'class' || n.kind === 'interface') && (n.language === 'cfml' || n.language === 'cfscript')); + const dirOf = (p: string): string => p.slice(0, p.lastIndexOf('/') + 1); + const beside = components.filter((n) => dirOf(n.filePath) === dirOf(fromFile)); + if (beside.length === 1) return beside[0]; + return components.length === 1 ? components[0] : undefined; + } + + /** A CFML component's method by its lowercased name (CFML names ignore case): its own, else one a supertype has. */ + private cfmlMethodOf(cls: Node, lowerName: string, seen: Set): Node | undefined { + if (seen.has(cls.id) || seen.size >= 8) return undefined; + seen.add(cls.id); + const own = this.context.getNodesInFile(cls.filePath).find((n) => + n.kind === 'method' && + n.name.toLowerCase() === lowerName && + n.qualifiedName.toLowerCase() === `${cls.name}::${n.name}`.toLowerCase() && + n.startLine >= cls.startLine && n.endLine <= cls.endLine + ); + if (own) return own; + for (const supertype of this.context.getSupertypeNodes?.(cls.id) ?? []) { + const inherited = this.cfmlMethodOf(supertype, lowerName, seen); + if (inherited) return inherited; + } + return undefined; } /**