diff --git a/CHANGELOG.md b/CHANGELOG.md index 3dee2195..634072fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- A call through a class name, like `THorse.Get(…)`, `MyView.as_view()` or `ColorChoices.values()`, now reaches the class method that class inherits. The inheritance is read from the class declarations themselves, in Pascal, Python, Ruby, PHP, TypeScript/JavaScript and the Java family. Before, such a call was resolved by guessing from the name. On Horse, `THorse.Get('/ping', …)` used to land on a route-group interface's `Get` 120 times. Re-index after upgrading. - In Objective-C, `[super init]` and other messages to `super` now reach the superclass's method, never the sending class's own override. This also works where the index lost the `@implementation`'s range. On SDWebImage, `NSObject` subclasses' `[super init]` used to land on `SDDiskCache`'s `init`, and `[self class]` in tests on a proxy class's `class`. On AFNetworking, a serializer's `[super encodeWithCoder:]` now reaches its superclass. Re-index after upgrading. - In Solidity, a call with no receiver now reaches a function of the contract around it or of one it inherits. On OpenZeppelin, `_msgSender()` in contracts built on `Context` used to land on `ERC2771Context`'s override 83 times. In Erlang, a call to another module's function now follows the file's `-import(Mod, [f/N])`. On cowboy, `-import(req_SUITE, [do_get/3])` calls used to land on another suite's `do_get/3`. A bare call with no such import no longer links to some other module. Re-index after upgrading. - Calls on a library's own types and modules no longer land on a project method that happens to share the name. In Rust, `Vec::new()`, `String::from(…)` and module paths like `task::spawn(…)` stay the standard library's. A later link of a multi-line Rust or Go chain now counts as a chained call, named after the chain's first line. In Go, `w.Header().Get(…)` is net/http's. In R, a plain call like `range(x)` is a function, not an object's method. In JavaScript, `response.text()` / `.json()` is fetch's. On tokio, `task::spawn(…)` used to land on `TaskTracker::spawn` 300 times. On ggplot2, `range(…)` used to land on a coordinate system's `range` method. Re-index after upgrading. diff --git a/__tests__/inherited-class-method.test.ts b/__tests__/inherited-class-method.test.ts new file mode 100644 index 00000000..317cb6c6 --- /dev/null +++ b/__tests__/inherited-class-method.test.ts @@ -0,0 +1,130 @@ +/** + * A call through a class name reaches a class method the class inherits, + * read from its own and its ancestors' declaration heads, before any guess by + * name: Horse's `THorse.Get('/ping', …)` is THorseCore's (three `class(…)` + * heads up), not a route-group interface's `Get`; DRF's `MyView.as_view()` is + * APIView's; netbox's `ColorChoices.values()` is ChoiceSet's. + */ +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-inherited-class-method-')); + const files: Record = { + 'src/Horse.Core.pas': `unit Horse.Core; + +interface + +type + THorseCore = class + public + class function Get(const APath: string): THorseCore; + end; + +implementation + +class function THorseCore.Get(const APath: string): THorseCore; +begin + Result := nil; +end; + +end. +`, + 'src/Horse.Core.Group.pas': `unit Horse.Core.Group; + +interface + +type + IHorseCoreGroup = interface + function Get(const APath: string): IHorseCoreGroup; + end; + +implementation + +end. +`, + 'src/Horse.pas': `unit Horse; + +interface + +uses Horse.Core; + +type + THorseProvider = class(THorseCore) + end; + + THorse = class(THorseProvider) + end; + +implementation + +end. +`, + 'samples/Console.dpr': `program Console; + +uses Horse; + +begin + THorse.Get('/ping'); +end. +`, + 'app/views.py': `class APIView: + @classmethod + def as_view(cls): + return cls + + +class GenericAPIView(APIView): + pass + + +class UserList(GenericAPIView): + pass + + +class Router: + def as_view(self): + return None +`, + 'app/urls.py': `from app.views import UserList + +urlpatterns = [UserList.as_view()] +`, + }; + 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 }); +}); + +/** `Owner::member` of every call edge out of a file. */ +function callsFrom(file: string): string[] { + const ids = cg.getNodesInFile(file).map((n) => n.id); + return cg + .getOutgoingEdgesFrom(ids) + .filter((e) => e.kind === 'calls') + .map((e) => cg.getNode(e.target)!.qualifiedName) + .sort(); +} + +describe('a class method reached through a subclass name', () => { + it('Pascal: walks the class(…) heads', () => { + expect(callsFrom('samples/Console.dpr').filter((q) => q.endsWith('Get'))).toEqual(['THorseCore::Get']); + }); + + it('Python: walks the class bases', () => { + expect(callsFrom('app/urls.py')).toEqual(['APIView::as_view']); + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 72c46245..c86166ee 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -4927,6 +4927,58 @@ function headTypeArguments(cls: Node, sup: string, context: ResolutionContext): return angleArguments(head.slice(at.index)).map((a) => a.replace(/<[\s\S]*$/, '').split('.').pop()!.trim()); } +/** + * The supertypes a class declaration names in its own head, for the + * languages whose heads say it plainly: Pascal `TX = class(TBase, IFoo)`, + * Python `class X(Base):`, Ruby `class X < Base`, PHP / TS / JS `extends + * Base`, and the Java-family heads. + */ +function declaredSupertypes(cls: Node, context: ResolutionContext): string[] { + switch (cls.language) { + case 'java': case 'csharp': case 'kotlin': return classHeadSupertypes(cls, context); + case 'dart': return dartSupertypesOf(cls.name, context); + case 'swift': return swiftDeclOf(cls.name, context).supers; + case 'objc': return objcSupertypesOf(cls.name, context); + case 'scala': return scalaSupertypesOf(cls.name, context); + default: break; + } + const lines = context.getFileLines?.(cls.filePath) ?? context.readFile(cls.filePath)?.split(/\r?\n/) ?? []; + const head = lines.slice(cls.startLine - 1, cls.startLine + 2).join(' '); + const names = (text: string | undefined): string[] => + text ? [...text.matchAll(/([A-Za-z_][\w.:\\]*)/g)].map((m) => m[1]!.split(/::|\.|\\/).pop()!).filter((w) => !/^(?:metaclass|object)$/.test(w)) : []; + switch (cls.language) { + case 'pascal': return names(/=\s*class\s*\(([^)]*)\)/i.exec(head)?.[1]); + case 'python': return names(/\bclass\s+\w+\s*\(([^)]*)\)/.exec(head)?.[1]?.replace(/\w+\s*=\s*[\w.]+/g, '')); + case 'ruby': return names(/\bclass\s+[\w:]+\s*<\s*([\w:]+)/.exec(head)?.[1]); + case 'php': case 'typescript': case 'tsx': case 'javascript': case 'jsx': + return names(/\bextends\s+([\w.\\]+)/.exec(head)?.[1]); + default: return []; + } +} + +/** A method named `name` on a supertype of the given classes, nearest first. */ +function inheritedClassMethod(classes: Node[], name: string, context: ResolutionContext): Node | null { + const seen = new Set(classes.map((c) => c.id)); + let frontier = classes; + for (let depth = 0; depth < 5 && frontier.length > 0; depth++) { + const next: Node[] = []; + for (const cls of frontier) { + for (const sup of declaredSupertypes(cls, context)) { + for (const decl of context.getNodesByName(sup)) { + if (decl.language !== cls.language || !isMethodOwnerKind(decl) || seen.has(decl.id)) continue; + seen.add(decl.id); + const method = context.getNodesInFile(decl.filePath).find((n) => n.kind === 'method' && n.name === name && + n.qualifiedName.slice(0, Math.max(0, n.qualifiedName.lastIndexOf('::'))).split(/::|\./).pop() === decl.name); + if (method) return method; + next.push(decl); + } + } + } + frontier = next; + } + return null; +} + /** The simple names a Java / C# / Kotlin class declaration's head extends or implements. */ function classHeadSupertypes(cls: Node, context: ResolutionContext): string[] { const lines = context.getFileLines?.(cls.filePath) ?? context.readFile(cls.filePath)?.split(/\r?\n/) ?? []; @@ -5749,6 +5801,10 @@ export function matchMethodCall( }; } } + // A class method the named class inherits — Horse's `THorse.Get(…)` is + // THorseCore's, three `class(…)` heads up — before any guess by name. + const inherited = inheritedClassMethod(classCandidates.filter((c) => c.language === ref.language), methodName!, context); + if (inherited) return { original: ref, targetNodeId: inherited.id, confidence: 0.8, resolvedBy: 'qualified-name' }; return null; }); if (strat1) return strat1;