diff --git a/CHANGELOG.md b/CHANGELOG.md index f2ff44e05..98b2ccbf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- In Python, a call through a module global holding a class whose base list is wrapped over several lines, as black and ruff write a long one, like `class Wrapped(\n Mixin,\n Base,\n):` then `current = Wrapped()` and `current.run()`, or through the class name, like `Wrapped.run(x)`, now reaches the method a base declares, as a one-line base list already did. Before, only the first line or three were read for this, so such a class seemed to have no bases. Re-index Python projects after upgrading. - In TypeScript, an awaited call with type arguments like `await fetchApi("/me")` or `await api.get(url)` now links to the function it calls. Before, the call was dropped, so typed fetch wrappers looked like they had no callers. (#2586) - In JavaScript and TypeScript, a call or `new` in a parameter's default value, like `function renderWithNote(note = buildNote())`, a destructured `{ size = defaultSize() }` or `constructor(private svc = new Service())`, now counts as the function's own call. Before, it was dropped, so `buildNote` was missing that caller and impact on it came back incomplete. Re-index JavaScript and TypeScript projects after upgrading. Thanks @inth3shadows for the report and the pull request. (#2588) - In React, a component rendered at module scope, in a top-level constant or inside a test's `it(…)` callback, like `createRoot(el).render()` or `render()`, now counts as rendered there, so an app's entry reaches its root component and a component's tests show among its callers. (#2587) diff --git a/__tests__/python-multiline-bases.test.ts b/__tests__/python-multiline-bases.test.ts new file mode 100644 index 000000000..ed94120a0 --- /dev/null +++ b/__tests__/python-multiline-bases.test.ts @@ -0,0 +1,182 @@ +/** + * A Python base list wrapped over several lines — as black and ruff write a + * long one, one base per line — is read whole by the resolver's own reader of + * a class's bases (`pythonBases`, behind module-global and `self.` dispatch). + * It read only the `class` line, so a module global holding such a class, + * `current = Wrapped()` then `current.run()`, reached no method its bases + * declare. Extraction already read the whole list. + */ +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-py-multiline-bases-')); + fs.mkdirSync(path.join(root, 'app')); + fs.writeFileSync(path.join(root, 'app/__init__.py'), ''); + fs.writeFileSync(path.join(root, 'app/m.py'), `from typing import Generic, TypeVar + +T = TypeVar("T") + + +class Base(Generic[T]): + def run(self): + pass + + +class Other: + def run(self): + pass + + +class Mixin: + pass + + +class OneLine(Mixin, Base): + pass + + +class Wrapped( + Mixin, + Base, +): + pass + + +class Commented( + Mixin, # no run here :) + # the base that declares run: + Base, +): + pass + + +class WrappedGeneric( + Mixin, + Base[ + dict[str, int] + ], +): + pass + + +current_a = OneLine() +current_b = Wrapped() +current_c = Commented() +current_d = WrappedGeneric() + + +def use_one_line(): + current_a.run() + + +def use_wrapped(): + current_b.run() + + +def use_commented(): + current_c.run() + + +def use_wrapped_generic(): + current_d.run() + + +def keep(cls): + return cls + + +@keep +class DecoratedOneLine(Mixin, Base): + pass + + +@keep +class DecoratedWrapped( + Mixin, + Base, +): + pass + + +def class_one_line(x): + OneLine.run(x) + + +def class_wrapped(x): + Wrapped.run(x) + + +def class_commented(x): + Commented.run(x) + + +def class_decorated_one_line(x): + DecoratedOneLine.run(x) + + +def class_decorated_wrapped(x): + DecoratedWrapped.run(x) +`); + cg = await CodeGraph.init(root, { index: true }); +}); + +afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); +}); + +/** Qualified names of the methods `fn` calls. */ +const calls = (fn: string): string[] => { + const node = cg.getNodesInFile('app/m.py').find((n) => n.name === fn && n.kind === 'function')!; + expect(node, fn).toBeDefined(); + return cg.getOutgoingEdges(node.id).filter((e) => e.kind === 'calls') + .map((e) => cg.getNode(e.target)!).filter((n) => n.kind === 'method').map((n) => n.qualifiedName); +}; + +describe('a Python base list wrapped over several lines', () => { + // Base and Other both declare `run`, and the globals are not named after + // their classes, so only the class's own bases can pick one. + it('resolves a module global through a one-line list (control)', () => { + expect(calls('use_one_line')).toEqual(['Base::run']); + }); + + it('resolves a module global through a wrapped list', () => { + expect(calls('use_wrapped')).toEqual(['Base::run']); + }); + + it('skips comments inside the list, parentheses and all', () => { + expect(calls('use_commented')).toEqual(['Base::run']); + }); + + it('reads a parameterized base wrapped inside the list', () => { + expect(calls('use_wrapped_generic')).toEqual(['Base::run']); + }); +}); + +// A call through the class name, `Wrapped.run(x)`, finds `run` through the +// supertypes the class head declares (`declaredSupertypes`), a second reader. +describe('a class-name call through a wrapped base list', () => { + it('resolves through a one-line list (control)', () => { + expect(calls('class_one_line')).toEqual(['Base::run']); + }); + + it('resolves through a wrapped list', () => { + expect(calls('class_wrapped')).toEqual(['Base::run']); + }); + + it('skips comments inside the list, parentheses and all', () => { + expect(calls('class_commented')).toEqual(['Base::run']); + }); + + it('reads the head of a decorated class, one line or wrapped', () => { + expect(calls('class_decorated_one_line')).toEqual(['Base::run']); + expect(calls('class_decorated_wrapped')).toEqual(['Base::run']); + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 2bdf9773e..212e969e5 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -410,10 +410,31 @@ function pythonRefClass(name: string, ref: UnresolvedRef, context: ResolutionCon return classes.length === 1 ? classes[0]! : null; } +/** + * A Python class's base list as written, up to its closing parenthesis, which + * may be lines below the `class` line: black and ruff wrap a long list one base + * per line. Comments in it are left out. Undefined for a class without one. + */ +function pythonBaseList(cls: Node, context: ResolutionContext): string | undefined { + const lines = context.getFileLines?.(cls.filePath) ?? context.readFile(cls.filePath)?.split('\n') ?? []; + const head = /^\s*class\s+\w+\s*\(/.exec(lines[cls.startLine - 1] ?? ''); + if (!head) return undefined; + let list = ''; + let depth = 1; + for (let i = cls.startLine - 1; i < Math.max(cls.endLine, cls.startLine); i++) { + const text = (i === cls.startLine - 1 ? lines[i]!.slice(head[0].length) : lines[i] ?? '').replace(/#.*$/, ''); + for (const ch of text) { + if (ch === '(') depth++; + else if (ch === ')' && --depth === 0) return list; + list += ch; + } + list += ' '; + } + return undefined; +} + function pythonBases(cls: Node, ref: UnresolvedRef, context: ResolutionContext): Node[] { - const line = context.getFileLines?.(cls.filePath)?.[cls.startLine - 1] - ?? context.readFile(cls.filePath)?.split('\n')[cls.startLine - 1] ?? ''; - let bases = line.match(/^\s*class\s+\w+\s*\(([^)]*)\)/)?.[1]; + let bases = pythonBaseList(cls, context); // A parameterized base, `Base[str]` or `Pair[str, Dict[str, int]]`, names its class before the brackets. while (bases && /\[[^[\]]*\]/.test(bases)) bases = bases.replace(/\[[^[\]]*\]/g, ''); return (bases?.split(',') ?? []).flatMap(name => { @@ -21517,8 +21538,10 @@ function readDeclaredSupertypes(cls: Node, context: ResolutionContext): string[] // BaseManager.from_queryset(QuerySet))` — subclasses that class: the // qualifier is the supertype, not the method called on it. // A parameterized base, `Base[User]`, is `Base`: its type arguments are no supertypes. + // The list is read whole, as `pythonBases` reads it: black and ruff wrap a + // long one past the three lines `head` holds. case 'python': { - let bases = /\bclass\s+\w+\s*\(([^)]*)\)/.exec(head)?.[1]; + let bases = pythonBaseList(cls, context); while (bases && /\[[^[\]]*\]/.test(bases)) bases = bases.replace(/\[[^[\]]*\]/g, ''); return names(bases?.replace(/\w+\s*=\s*[\w.]+/g, '').replace(/([A-Za-z_][\w.]*)\.\w+\s*\(/g, '$1 (')); }