diff --git a/CHANGELOG.md b/CHANGELOG.md index dd8a4d1d43..204eb7343f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -204,6 +204,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In Java, Kotlin, C# and Scala, each overload of an interface or base-class method now links only to the overrides with the same parameters. Before, every overload linked to every method of that name in the implementing class, so `ExampleService.putTairValue(String)` also led to `putTairValue(List, double)`, and `codegraph_explore`'s call path, callers and impact went into overloads that call never runs. Where a parameter is a type parameter, like `save(T entity)` implemented as `save(String entity)`, or the class is written in another language than its interface, the number of parameters decides; a method declared once on each side links as before. Re-index Java, Kotlin, C# and Scala projects after upgrading. Thanks @hxj1225 for the report and @danusha2345 for the pull request. (#2663) - In Kotlin, a call on what one of your functions returns, like `repository().pending(1)` or `val repo = repository(); repo.pending(1)` with a `private fun repository(): RealRepo` in a test class, now links to the method of the type that function declares, including one the type inherits or an extension on it. Before, the chained call went to whichever `pending` the name matched first, like a test double's in another package, and the local was guessed from its name, which reached the interface. The function is found in the calling class, a class it inherits from or one around it, or at the top level, and a nested type like `Request.Builder` is that type, not another class's `Builder`. When the declared type has no such method, the call now links to nothing rather than a method of the same name elsewhere; a type from outside your project is matched by name as before. Re-index Kotlin projects after upgrading. Thanks @mindcipherrr for the report. (#2805) - In JavaScript and TypeScript, a `super.run()` call now links to the method of the class's parent, or of the nearest ancestor that has it, like three.js's `super.copy(source)` reaching `Object3D.copy`, so `codegraph_explore`'s call path and impact follow an override into the method it builds on. Before, such a call inside a method of the same name was dropped, and elsewhere it went to whatever method had the name, often the class's own or a sibling class's in the same file. A call from a static method reaches the parent's static method, and a call to a parent from outside your project, like `HTMLElement`, `Set` or `Promise`, links nothing. Re-index JavaScript and TypeScript projects after upgrading. Thanks @Puma7 for the report and the pull request. (#2497) +- In JavaScript and TypeScript, a named class expression, like `register(class Widget extends Base {…})`, `customElements.define('x-card', class XCard extends HTMLElement {…})` or `const Panel = class PanelImpl extends Base {…}`, is now indexed as a class with its own methods and the class it extends, so calls between its methods and its `super` calls link as a class declaration's do. Before, its methods were free functions or weren't indexed at all, and it extended nothing. Its name stays bound inside the expression alone, as JavaScript has it: `new Widget()` elsewhere links nothing, a constant, class or interface of the same name around it keeps its links, and code outside never reaches its methods by a guess. A class a CommonJS module exports, like webpack's `module.exports = class MultiCompiler {…}` or `exports.Store = class Store {…}`, is reached through `require` as a class declaration is, along with its static methods and the methods called on its instances. Re-index JavaScript and TypeScript projects after upgrading. Thanks @Puma7 for the report and the pull request. (#2499) - In Go, a call through a struct field that holds a function, like prometheus's `d.refreshf(ctx)`, now reaches the functions put in that field, so `codegraph_explore` follows service discovery from `Discovery.Run` into each provider's `refresh` and names the line where each provider wires its method in, like `RefreshF: d.refresh`. The field can be set in a struct literal or by an assignment, copied from another struct's field (`refreshf: opts.RefreshF`), or given a method of an interface value, and two unrelated structs with a field of the same name, like `fn` or `handler`, stay apart. Before, such a flow stopped at the field. Re-index Go projects after upgrading. - In Go, a type defined from other types, like etcd's `type WatchChan <-chan WatchResponse` or gin's `type HandlerFunc func(*Context)` and `type HandlersChain []HandlerFunc`, now links to the types it is built from, so impact on `WatchResponse` or gin's `Context` reaches these types and the code that uses them. Before, such a type linked to nothing. One written through another package, like etcd's `type PutResponse pb.PutResponse`, links to that package's type, and a recursive one like `type stateFn func(*Lexer) stateFn` doesn't link to itself. Re-index Go projects after upgrading. - In Go, a struct now links to the types its fields are declared with, like etcd's `PutResponse` holding a `*ResponseHeader` or gin's `Context` holding a `HandlersChain`, so impact on `ResponseHeader` or `HandlersChain` reaches every struct that holds one and the code that uses those structs, and `codegraph_node` lists a struct's field types. Before, a struct linked only to the types it embeds. A field written through another package, like `*pb.ResponseHeader`, links to that package's type, one from outside your project, like `context.Context`, links to nothing, the types an embedded generic is filled in with, like the `Pod` in client-go's `listers.ResourceIndexer[*v1.Pod]`, link too, and a struct that holds itself, like a list node's `next *node`, doesn't link to itself. `codegraph_explore` still answers a question about a flow with the code that runs it, not with the structs that merely hold one of its types. Re-index Go projects after upgrading. diff --git a/__tests__/js-class-expressions.test.ts b/__tests__/js-class-expressions.test.ts new file mode 100644 index 0000000000..ec4761d3bb --- /dev/null +++ b/__tests__/js-class-expressions.test.ts @@ -0,0 +1,287 @@ +/** + * A named class expression, like `register(class Widget extends Base {…})` or + * `const Panel = class PanelImpl extends Base {…}`, is a class: it has a node, + * members of its own and the supertypes it names (#2499). Before, its methods + * were free functions or not indexed at all, and it extended nothing. Its + * name is bound inside the expression alone, so outside it the name reaches + * neither the class nor its members, and a binding of the same name around + * it keeps meaning what it meant. A class a CommonJS module exports is the + * module's value, which `require` hands over as it does a declaration. + */ +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; +import { extractFromSource } from '../src/extraction'; +import { initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars'; +import { tryKernelExtract, resetKernelForTests } from '../src/extraction/kernel'; +import type { ExtractionResult, Language } from '../src/types'; + +const KERNEL_PATH = path.join(__dirname, '..', 'codegraph-kernel', 'prebuilds', `${process.platform}-${process.arch}`, 'codegraph-kernel.node'); +const kernelAvailable = fs.existsSync(KERNEL_PATH) || process.env.CODEGRAPH_KERNEL_EXPECT === '1'; + +const EXPRESSIONS = `class Base { run() { return 1; } } +register(class Widget extends Base { + run() { return super.run(); } + extra() { return this.run(); } +}); +export const Panel = class PanelImpl extends Base { + go() { return 3; } +}; +register(class extends Base { anonymous() { return 4; } }); +`; + +const ENV_KEYS = ['CODEGRAPH_KERNEL', 'CODEGRAPH_KERNEL_LANGS'] as const; + +describe('a named class expression is a class (#2499)', () => { + let savedEnv: Record = {}; + + beforeAll(async () => { + await initGrammars(); + await loadGrammarsForLanguages(['typescript', 'tsx', 'javascript', 'jsx']); + }); + + beforeEach(() => { + savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]])); + resetKernelForTests(); + }); + + afterEach(() => { + for (const k of ENV_KEYS) { + if (savedEnv[k] === undefined) delete process.env[k]; + else process.env[k] = savedEnv[k]; + } + resetKernelForTests(); + }); + + function extract(backend: 'kernel' | 'wasm', file: string, source: string, language: Language): ExtractionResult { + if (backend === 'wasm') { + process.env.CODEGRAPH_KERNEL = '0'; + return extractFromSource(file, source, language); + } + delete process.env.CODEGRAPH_KERNEL; + process.env.CODEGRAPH_KERNEL_LANGS = 'all'; + const result = tryKernelExtract(file, source, language); + expect(result, `kernel extraction of ${file}`).not.toBeNull(); + return result!; + } + + const backends = kernelAvailable ? (['kernel', 'wasm'] as const) : (['wasm'] as const); + const variants = [['ts', 'typescript'], ['tsx', 'tsx'], ['js', 'javascript'], ['jsx', 'jsx']] as const; + + for (const backend of backends) { + it.each(variants)(`in %s, ${backend}`, (ext, language) => { + const result = extract(backend, `src/widgets.${ext}`, EXPRESSIONS, language); + const symbols = result.nodes + .filter((n) => n.kind === 'class' || n.kind === 'method' || n.kind === 'function') + .map((n) => `${n.kind} ${n.qualifiedName}${n.signature && n.kind === 'class' ? ` (${n.signature})` : ''}${n.isExported ? ' exported' : ''}`); + expect(symbols).toEqual([ + 'class Base', + 'method Base::run', + 'class Widget (class Widget extends Base)', + 'method Widget::run', + 'method Widget::extra', + 'class Panel::PanelImpl (class PanelImpl extends Base)', + 'method Panel::PanelImpl::go', + // An anonymous class expression is passed over, as before. + 'function anonymous', + ]); + const supertypes = result.unresolvedReferences + .filter((r) => r.referenceKind === 'extends') + .map((r) => `${result.nodes.find((n) => n.id === r.fromNodeId)?.qualifiedName} extends ${r.referenceName}`); + expect(supertypes).toEqual(['Widget extends Base', 'Panel::PanelImpl extends Base']); + }); + } + + it.runIf(kernelAvailable)('the kernel and the wasm extractor agree on every node and reference', () => { + for (const [ext, language] of variants) { + const canon = (r: ExtractionResult) => ({ + nodes: r.nodes.map(({ updatedAt: _u, ...n }) => JSON.stringify(n, Object.keys(n).sort())).sort(), + refs: r.unresolvedReferences.map((x) => JSON.stringify(x, Object.keys(x).sort())).sort(), + }); + expect(canon(extract('kernel', `src/widgets.${ext}`, EXPRESSIONS, language)), ext) + .toEqual(canon(extract('wasm', `src/widgets.${ext}`, EXPRESSIONS, language))); + } + }); +}); + +describe.each(['js', 'ts'])('a class expression\'s name reaches it from inside alone (#2499): %s', (ext) => { + let root = ''; + let graph: CodeGraph | null = null; + + afterEach(() => { + graph?.close(); + graph = null; + if (root) fs.rmSync(root, { recursive: true, force: true }); + root = ''; + }); + + /** `kind source -> target [resolvedBy confidence]` for the calls, supertypes and instantiations out of a file's nodes, synthesized ones left out. */ + function edgesFrom(g: CodeGraph, file: string): string[] { + const out: string[] = []; + for (const node of g.getNodesInFile(file)) { + for (const e of g.getOutgoingEdges(node.id)) { + if (!['calls', 'extends', 'implements', 'instantiates'].includes(e.kind) || e.metadata?.synthesizedBy) continue; + out.push(`${e.kind} ${node.qualifiedName} -> ${g.getNode(e.target)!.qualifiedName} [${e.metadata?.resolvedBy} ${e.metadata?.confidence}]`); + } + } + return out.sort(); + } + + it('links the class\'s supertypes, super and this calls, and nothing outside by its name', async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-class-expression-')); + const write = (rel: string, lines: string[]) => { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), lines.join('\n') + '\n'); + }; + write('package.json', [JSON.stringify({ name: 'app' })]); + write(`src/base.${ext}`, ['export class Base { run() { return 1; } }', 'export function register(T) { return T; }']); + write(`src/widgets.${ext}`, [ + "import { Base, register } from './base';", + 'register(class Widget extends Base {', + ' run() { return super.run(); }', + ' extra() { return this.run(); }', + // Another class expression's name is no binding in here either. + ' other() { return new PanelImpl(); }', + '});', + 'export const Panel = class PanelImpl extends Base {', + ' go() { return super.run(); }', + '};', + // The inner name is no binding out here: this throws at runtime. + 'export function make() { return new Widget(); }', + 'export function panel() { return new Panel(); }', + ]); + write(`src/shadow.${ext}`, [ + "import { Base, register } from './base';", + // A declaration and a constant share names with class expressions in their file. + 'export class Gadget { run() { return 0; } }', + 'export const list = [class Gadget extends Base { run() { return super.run(); } }];', + 'export const Tool = class Tool extends Base { static make() { return new Tool(); } };', + 'export function build() { return new Gadget(); }', + 'export function tool() { return new Tool(); }', + 'export function helper() { return Tool.make(); }', + ]); + // nitro's shape: an interface, and a class expression of its name that a + // factory returns. A value typed by the name is the interface's. + if (ext === 'ts') { + write('src/env.ts', [ + 'export interface Env { fetch(url: string): string }', + 'export function define() { return class Env { fetch(url: string) { return url; } }; }', + 'export function use(env: Env) { return env.fetch("/"); }', + ]); + } + graph = await CodeGraph.init(root, { index: true }); + + expect(edgesFrom(graph, `src/widgets.${ext}`)).toEqual([ + 'calls Panel::PanelImpl::go -> Base::run [instance-method 0.9]', + 'calls Widget::extra -> Widget::run [exact-match 0.4]', + 'calls Widget::run -> Base::run [instance-method 0.9]', + `calls src/widgets.${ext} -> register [import 0.9]`, + 'extends Panel::PanelImpl -> Base [import 0.9]', + 'extends Widget -> Base [import 0.9]', + // `new Panel()` is the constant's, as before; `new Widget()` outside + // the expression links nothing. + 'instantiates panel -> Panel [exact-match 0.9]', + ]); + expect(edgesFrom(graph, `src/shadow.${ext}`)).toEqual([ + 'calls list::Gadget::run -> Base::run [instance-method 0.9]', + 'extends Tool::Tool -> Base [import 0.9]', + 'extends list::Gadget -> Base [import 0.9]', + // Outside the expressions, the declaration and the constant keep the + // names, at the confidence they had; inside its own body, `Tool` is + // the class. + 'instantiates Tool::Tool::make -> Tool::Tool [exact-match 0.4]', + 'instantiates build -> Gadget [exact-match 0.9]', + 'instantiates tool -> Tool [exact-match 0.9]', + ]); + if (ext === 'ts') { + expect(edgesFrom(graph, 'src/env.ts').filter((e) => e.startsWith('calls use'))).toEqual(['calls use -> Env::fetch [instance-method 0.9]']); + } + }, 120_000); +}); + +it('a guess by a receiver\'s name never reaches a class expression\'s member from code its value can\'t reach (#2499)', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-class-expression-guess-')); + let graph: CodeGraph | null = null; + try { + fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'app' })); + fs.mkdirSync(path.join(root, 'src')); + // lit's SSR DOM shim declares its document and elements this way. + fs.writeFileSync(path.join(root, 'src/dom-shim.ts'), [ + 'export const DocumentShim = class Document {', + ' createElement(tag: string) { return tag; }', + '};', + 'export const HTMLElementShim = class HTMLElement {', + ' appendChild(child: unknown) { return child; }', + '};', + '', + ].join('\n')); + fs.writeFileSync(path.join(root, 'src/format.ts'), 'export class Formatter { appendChild(node: unknown) { return node; } }\n'); + fs.writeFileSync(path.join(root, 'src/view.ts'), [ + 'let container: HTMLElement;', + "export function view() { container = document.createElement('div'); return container; }", + // A class expression of its own, which it reaches. + 'export const Local = class LocalView { show() { return 1; } };', + 'export function mount(el: unknown) { container.appendChild(el); }', + '', + ].join('\n')); + graph = await CodeGraph.init(root, { index: true }); + for (const name of ['view', 'mount']) { + const fn = graph.getNodesByQualifiedName(name)[0]!; + expect(graph.getOutgoingEdges(fn.id).filter((e) => e.kind === 'calls').map((e) => graph!.getNode(e.target)!.qualifiedName), name).toEqual([]); + } + } finally { + graph?.close(); + fs.rmSync(root, { recursive: true, force: true }); + } +}, 120_000); + +it('a class a CommonJS module exports is reached through `require` as a declaration is (#2499)', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-class-expression-cjs-')); + let graph: CodeGraph | null = null; + try { + fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'app' })); + fs.mkdirSync(path.join(root, 'src')); + fs.writeFileSync(path.join(root, 'src/router.js'), [ + 'module.exports = class Router {', + ' static make() { return 1; }', + ' route() { return 2; }', + '};', + // A class expression the module doesn't export keeps its scope. + 'register(class Hidden { go() { return 3; } });', + '', + ].join('\n')); + fs.writeFileSync(path.join(root, 'src/store.js'), 'exports.Store = class StoreImpl {\n static open() { return 4; }\n};\n'); + fs.writeFileSync(path.join(root, 'src/app.js'), [ + "const Router = require('./router.js');", + "const { Store } = require('./store.js');", + 'exports.start = function start() {', + ' const r = new Router();', + ' Router.make();', + ' Store.open();', + ' new Hidden().go();', + ' return r.route();', + '};', + '', + ].join('\n')); + graph = await CodeGraph.init(root, { index: true }); + const start = graph.getNodesByQualifiedName('start')[0]!; + const targets = graph.getOutgoingEdges(start.id) + .filter((e) => e.kind === 'calls' || e.kind === 'instantiates') + .map((e) => `${e.kind} ${graph!.getNode(e.target)!.kind} ${graph!.getNode(e.target)!.qualifiedName} [${e.metadata?.resolvedBy}]`) + .sort(); + // The class, its static method and its instance's method, by what each + // module exports, `module.exports` or `exports.Store`; the class + // expression neither exports stays out of reach. + expect(targets).toEqual([ + 'calls method Router::make [import]', + 'calls method Router::route [instance-method]', + 'calls method StoreImpl::open [import]', + 'instantiates class Router [import]', + ]); + } finally { + graph?.close(); + fs.rmSync(root, { recursive: true, force: true }); + } +}, 120_000); diff --git a/codegraph-kernel/src/tsjs/extractors.rs b/codegraph-kernel/src/tsjs/extractors.rs index 61b343825a..e717f5a8de 100644 --- a/codegraph-kernel/src/tsjs/extractors.rs +++ b/codegraph-kernel/src/tsjs/extractors.rs @@ -368,10 +368,15 @@ impl<'t> Walker<'t> { pub(super) fn extract_class(&mut self, node: Node<'t>) { let resolved_body = body_of(node); // skipBodilessClass unset for TS/JS let name = self.extract_name(node); + // A class expression's name is bound inside the expression alone: + // never exported by it, and its head is its signature, which a + // declaration lacks (#2499). Mirrors TreeSitterExtractor.extractClass. + let expression = node.kind() == "class"; let extra = Extra { docstring: crate::docstring::preceding_docstring(node, self.src), visibility: self.visibility_of(node), - is_exported: Some(self.is_exported(node)), + is_exported: Some(!expression && self.is_exported(node)), + signature: if expression { resolved_body.map(|body| class_expression_head(self.src, node, body)) } else { None }, ..Extra::default() }; let Some(row) = self.create_node("class", &name, node, extra) else { @@ -2014,6 +2019,15 @@ impl<'t> Walker<'t> { } } +/// A class expression's head, `class Widget extends Base`: its text up to the +/// body, trailing spaces and line breaks dropped. Mirrors classExpressionHead +/// (tree-sitter.ts). +fn class_expression_head(src: &str, node: Node<'_>, body: Node<'_>) -> String { + src[node.start_byte()..body.start_byte()] + .trim_end_matches(|c: char| matches!(c, ' ' | '\t' | '\r' | '\n')) + .to_string() +} + /// `.replace(/\s+/g, ' ')` for the tuple-contract signature. fn collapse_ws(s: &str) -> String { let mut out = String::with_capacity(s.len()); diff --git a/codegraph-kernel/src/tsjs/mod.rs b/codegraph-kernel/src/tsjs/mod.rs index 12eea4be1a..cd53f3255b 100644 --- a/codegraph-kernel/src/tsjs/mod.rs +++ b/codegraph-kernel/src/tsjs/mod.rs @@ -82,7 +82,16 @@ fn is_function_type(kind: &str) -> bool { } fn is_class_type(v: Variant, kind: &str) -> bool { - kind == "class_declaration" || (v.is_ts() && kind == "abstract_class_declaration") + kind == "class_declaration" || kind == "class" || (v.is_ts() && kind == "abstract_class_declaration") +} + +/// Whether `node` is a class to extract: a class declaration, or a class +/// expression (`class`) with a name, `register(class Widget extends Base +/// {…})` (#2499); an anonymous one is passed over. Mirrors +/// TreeSitterExtractor.isClassTypeNode. +fn is_class_node(v: Variant, node: Node) -> bool { + let kind = node.kind(); + is_class_type(v, kind) && (kind != "class" || node.child_by_field_name("name").is_some()) } fn is_variable_type(kind: &str) -> bool { @@ -692,7 +701,7 @@ impl<'t> Walker<'t> { // `declare function`, a function in a `declare module` body or a `.d.ts`. self.extract_function(node, None); skip_children = true; - } else if is_class_type(self.variant, kind) { + } else if is_class_node(self.variant, node) { self.extract_class(node); skip_children = true; } else if is_method_type(self.variant, kind) @@ -839,7 +848,7 @@ impl<'t> Walker<'t> { } } - if is_class_type(self.variant, kind) { + if is_class_node(self.variant, node) { self.extract_class(node); return; } diff --git a/src/extraction/languages/javascript.ts b/src/extraction/languages/javascript.ts index b96956e466..17920e7fde 100644 --- a/src/extraction/languages/javascript.ts +++ b/src/extraction/languages/javascript.ts @@ -4,7 +4,8 @@ import { classifyTsClassMember, hasStaticModifier } from './typescript'; export const javascriptExtractor: LanguageExtractor = { functionTypes: ['function_declaration', 'generator_function_declaration', 'arrow_function', 'function_expression', 'generator_function'], - classTypes: ['class_declaration'], + // A named class expression too, as in TypeScript (#2499). + classTypes: ['class_declaration', 'class'], methodTypes: ['method_definition', 'field_definition'], // JS `field_definition` ≙ TS `public_field_definition`: plain fields are // properties, function-valued fields are methods (#808). diff --git a/src/extraction/languages/typescript.ts b/src/extraction/languages/typescript.ts index 40f5a64655..ec28e1e829 100644 --- a/src/extraction/languages/typescript.ts +++ b/src/extraction/languages/typescript.ts @@ -150,7 +150,10 @@ export const typescriptExtractor: LanguageExtractor = { preParse: blankFlowSyntax, functionTypes: ['function_declaration', 'generator_function_declaration', 'arrow_function', 'function_expression', 'generator_function'], isAmbientFunction: isAmbientFunctionSignature, - classTypes: ['class_declaration', 'abstract_class_declaration'], + // `class` is a class expression, `register(class Widget extends Base {…})`: + // a named one is a class with its members and supertypes (#2499), and an + // anonymous one is passed over (TreeSitterExtractor.isClassTypeNode). + classTypes: ['class_declaration', 'abstract_class_declaration', 'class'], // `method_signature` is the interface/type-literal form of a method; without it // an interface's members never enter the graph, so a `.d.ts` platform API has // no declaration node for call sites to attach to (#1638). Java/C# don't need diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 7310dc15b9..162714c979 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -430,6 +430,14 @@ const LITERAL_RECEIVER_TYPES = new Set([ */ const TS_JS_CHAIN_LANGUAGES = new Set(['typescript', 'tsx', 'javascript', 'jsx']); +/** Languages whose `class` node is a class expression (#2499). */ +const CLASS_EXPRESSION_LANGUAGES = new Set(['typescript', 'tsx', 'javascript', 'jsx', 'arkts']); + +/** A class expression's head, `class Widget extends Base`: its text up to the body. Mirrored in the kernel (tsjs extract_class). */ +function classExpressionHead(source: string, node: SyntaxNode, body: SyntaxNode): string { + return source.substring(node.startIndex, body.startIndex).replace(/[ \t\r\n]+$/, ''); +} + /** Receiver node types (TS/JS grammars) that continue a member chain downward. */ const TS_JS_CHAIN_RECEIVER_TYPES = new Set(['member_expression', 'subscript_expression']); @@ -1543,7 +1551,7 @@ export class TreeSitterExtractor { skipChildren = true; } // Check for class declarations - else if (this.extractor.classTypes.includes(nodeType)) { + else if (this.isClassTypeNode(node, nodeType)) { // Some languages reuse class_declaration for structs/enums (e.g. Swift) const classification = this.extractor.classifyClassNode?.(node) ?? 'class'; if (classification === 'struct') { @@ -2540,6 +2548,16 @@ export class TreeSitterExtractor { /** * Extract a class */ + /** + * Whether `node` is a class to extract: one of the language's class types, + * except a TS/JS class expression without a name (`register(class extends + * Base {…})`), which is passed over as before (#2499). + */ + private isClassTypeNode(node: SyntaxNode, nodeType: string): boolean { + if (!this.extractor!.classTypes.includes(nodeType)) return false; + return nodeType !== 'class' || !CLASS_EXPRESSION_LANGUAGES.has(this.language) || !!getChildByField(node, 'name'); + } + private extractClass(node: SyntaxNode, kind: NodeKind = 'class'): void { if (!this.extractor) return; @@ -2555,13 +2573,19 @@ export class TreeSitterExtractor { const name = extractName(node, this.source, this.extractor); const docstring = this.docstringFor(node); const visibility = this.extractor.getVisibility?.(node); - const isExported = this.extractor.isExported?.(node, this.source); + // A class expression's name is bound inside the expression alone, so the + // class is never what a module exports by it, and it carries its head + // (`class Widget extends Base`) as its signature: a class declaration + // has none, which is how resolution tells the two apart (#2499). + const expression = node.type === 'class' && CLASS_EXPRESSION_LANGUAGES.has(this.language); + const isExported = expression ? false : this.extractor.isExported?.(node, this.source); const scoped = this.writtenScopeQualifiedName(node, name); const classNode = this.createNode(kind, name, node, { docstring, visibility, isExported, + ...(expression && resolvedBody ? { signature: classExpressionHead(this.source, node, resolvedBody) } : {}), ...this.cppBodyEnd(resolvedBody), ...(scoped !== undefined ? { qualifiedName: scoped } : {}), }); @@ -7420,7 +7444,7 @@ export class TreeSitterExtractor { // Extract structural nodes found inside function bodies. // Each extract method visits its own children, so we return after extracting. - if (this.extractor!.classTypes.includes(nodeType)) { + if (this.isClassTypeNode(node, nodeType)) { const classification = this.extractor!.classifyClassNode?.(node) ?? 'class'; if (classification === 'struct') this.extractStruct(node); else if (classification === 'enum') this.extractEnum(node); diff --git a/src/resolution/alias-binding.ts b/src/resolution/alias-binding.ts index 1af7958e8e..0f3ff6c601 100644 --- a/src/resolution/alias-binding.ts +++ b/src/resolution/alias-binding.ts @@ -131,11 +131,12 @@ export function extractLocalExportAliases(content: string): Array<{ exportedName } } } - // CommonJS: `exports.setCharset = function setCharset(…)`, `module.exports.x = impl`. - const memberRe = /^[ \t]*(?:module\.)?exports\.([A-Za-z_$][\w$]*)\s*=\s*(?:(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)\s*\(|([A-Za-z_$][\w$]*)\s*;?[ \t]*$)/gm; + // CommonJS: `exports.setCharset = function setCharset(…)`, `module.exports.x = impl`, + // and a named class expression, `exports.Router = class Router {…}` (#2499). + const memberRe = /^[ \t]*(?:module\.)?exports\.([A-Za-z_$][\w$]*)\s*=\s*(?:(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)\s*\(|class\s+(?!extends\b)([A-Za-z_$][\w$]*)|([A-Za-z_$][\w$]*)\s*;?[ \t]*$)/gm; let member: RegExpExecArray | null; while ((member = memberRe.exec(content)) !== null) { - out.push({ exportedName: member[1]!, localName: member[2] ?? member[3]! }); + out.push({ exportedName: member[1]!, localName: member[2] ?? member[3] ?? member[4]! }); } // `module.exports = { parse, format: formatDate }`. const objectRe = /^[ \t]*module\.exports\s*=\s*\{([^{}]*)\}/gm; diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 5889a4fa98..e2e717afc1 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -40,6 +40,7 @@ import { clearCFeatureVariantMemos } from './c-feature-variants'; import { clearAmbientModuleMemos, ambientModuleDeclarations } from './ambient-modules'; import { matchShopifyThemeFile } from './shopify-themes'; import { isJsSuperCallLanguage, isJsSuperCallSite, matchJsSuperCall } from './js-super-calls'; +import { CLASS_EXPRESSION_REF_LANGUAGES, isCommonJsExportedClass, isJsClassExpression, writtenIn, type ClassExpressionIndex } from './js-class-expressions'; import { resolveViaImport, resolvePhpImportedStaticCall, resolvePhpQualifiedClassRef, resolveTsQualifiedTypeRef, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef, isNixPathImportRef, isDartImportRef, isLuaRequireRef, isJsPathImportRef, isBoundToOutOfRepoImport, reachesAmbientModule, importBindingTail, clearImportResolverMemos, resolveImportPath, isExternalImport, ESM_IMPORT_LANGUAGES, jsModuleFile, pythonRootFingerprint, pythonReopenScope, pythonPackageImporters, moduleDefaultExport, isJsFamilyFile, isModuleBindingName } from './import-resolver'; import { ResolverPool, minRefsForPool, shouldEngageAdaptively } from './resolver-pool'; import { resolveAliasBinding } from './alias-binding'; @@ -575,6 +576,10 @@ export class ReferenceResolver { this.mocklessContext = null; this.mocklessBase = null; this.manualMocks = undefined; + this.classExpressionFreeViews = new WeakMap(); + this.classExpressionFreeBases = new WeakMap(); + this.classExpressionIndex = undefined; + this.classExpressionOnlyNameSet = null; this.nodeCache.clear(); this.fileCache.clear(); this.importMappingCache.clear(); @@ -1193,33 +1198,37 @@ export class ReferenceResolver { */ private hasAnyPossibleMatch(name: string): boolean { if (!this.knownNames) return true; // no pre-filter available + // In the view outside the class expressions, names only their classes + // and members carry name nothing (classExpressionFreeContextFor). + const only = this.classExpressionFreeBases.has(this.context) ? this.classExpressionOnlyNames() : null; + const known = (n: string): boolean => this.knownNames!.has(n) && !(only?.has(n) ?? false); // Direct name match - if (this.knownNames.has(name)) return true; + if (known(name)) return true; // For qualified names like "obj.method" or "Class::method", check the parts const dotIdx = name.indexOf('.'); if (dotIdx > 0) { const receiver = name.substring(0, dotIdx); const member = name.substring(dotIdx + 1); - if (this.knownNames.has(receiver) || this.knownNames.has(member)) return true; + if (known(receiver) || known(member)) return true; // Also check capitalized receiver (instance-method resolution) const capitalized = receiver.charAt(0).toUpperCase() + receiver.slice(1); - if (this.knownNames.has(capitalized)) return true; + if (known(capitalized)) return true; // JVM FQN: `com.example.foo.Bar` — the only useful segment is the // last one (`Bar`); the earlier check finds `example.foo.Bar` which // never matches a node name. const lastDot = name.lastIndexOf('.'); if (lastDot > dotIdx) { const tail = name.substring(lastDot + 1); - if (tail && this.knownNames.has(tail)) return true; + if (tail && known(tail)) return true; } } const colonIdx = name.indexOf('::'); if (colonIdx > 0) { const receiver = name.substring(0, colonIdx); const member = name.substring(colonIdx + 2); - if (this.knownNames.has(receiver) || this.knownNames.has(member)) return true; + if (known(receiver) || known(member)) return true; // Multi-segment path `a::b::c` (a Rust/C++ module call like // `database::profiles::find`) — the only segment that names a symbol is // the last (`c`); `member` above is `b::c`, which never matches a node @@ -1228,7 +1237,7 @@ export class ReferenceResolver { const lastColon = name.lastIndexOf('::'); if (lastColon > colonIdx) { const tail = name.substring(lastColon + 2); - if (tail && this.knownNames.has(tail)) return true; + if (tail && known(tail)) return true; } } @@ -1242,9 +1251,9 @@ export class ReferenceResolver { if (sepIdx > 0) { const receiver = name.substring(0, sepIdx); const member = name.substring(sepIdx + 1); - if (this.knownNames.has(member) || this.knownNames.has(receiver)) return true; + if (known(member) || known(receiver)) return true; const capitalized = receiver.charAt(0).toUpperCase() + receiver.slice(1); - if (this.knownNames.has(capitalized)) return true; + if (known(capitalized)) return true; } } @@ -1252,7 +1261,7 @@ export class ReferenceResolver { const slashIdx = name.lastIndexOf('/'); if (slashIdx > 0) { const fileName = name.substring(slashIdx + 1); - if (this.knownNames.has(fileName)) return true; + if (known(fileName)) return true; } return false; @@ -1380,11 +1389,20 @@ export class ReferenceResolver { resolveOne(ref: UnresolvedRef): ResolvedRef | null { // Production code is resolved as if the project's manual mocks were not - // indexed (see mocklessContextFor); every strategy below reads the graph - // through this.context, so it answers for that view. + // indexed (see mocklessContextFor), and code outside the named class + // expressions as if their classes were not (classExpressionFreeContextFor); + // every strategy below reads the graph through this.context, so it + // answers for that view. + const outer = this.context; + const unviewed = this.classExpressionFreeBases.get(outer) ?? outer; + const base = unviewed === this.mocklessContext && this.mocklessBase ? this.mocklessBase : unviewed; + const mockless = this.mocklessContextFor(ref, base) ?? base; + return this.resolveOneWith(this.classExpressionFreeContextFor(ref, mockless) ?? mockless, ref); + } + + /** resolveOneIn with every strategy reading the graph through `context`. */ + private resolveOneWith(context: ResolutionContext, ref: UnresolvedRef): ResolvedRef | null { const outer = this.context; - const base = outer === this.mocklessContext && this.mocklessBase ? this.mocklessBase : outer; - const context = this.mocklessContextFor(ref, base) ?? base; if (context === outer) return this.resolveOneIn(ref); this.context = context; try { @@ -3959,6 +3977,149 @@ export class ReferenceResolver { return this.manualMocks; } + /** + * The context a TS/JS reference is resolved in outside the named class + * expressions (#2499): one without the classes they declare or their + * members. An expression's name is bound inside it alone, and its class's + * value travels in ways resolution doesn't follow. So outside, a reference + * means what it would were those classes not indexed, with the confidence + * it had: `const Panel = class Panel {…}` keeps `new Panel()` on the + * constant, an interface beside a same-named class expression keeps its + * typed calls, and an `HTMLElement` type stays clear of lit's + * `HTMLElementShim = class HTMLElement {…}`. A class a CommonJS module + * exports isn't among them (classExpressions). Null inside a class + * expression, where the whole graph applies. + */ + private classExpressionFreeContextFor(ref: UnresolvedRef, base: ResolutionContext): ResolutionContext | null { + if (!CLASS_EXPRESSION_REF_LANGUAGES.has(ref.language)) return null; + const expressions = this.classExpressions(); + if (!expressions) return null; + const dot = ref.referenceName.indexOf('.'); + const named = expressions.byName.get(dot > 0 ? ref.referenceName.slice(0, dot) : ref.referenceName); + // Written in a class expression, a reference sees its class and the + // classes around it, as JavaScript scopes them, unless it names another + // one, which is bound only inside that one. + const inside = expressions.byFile.get(ref.filePath)?.some((n) => writtenIn(n, ref)) ?? false; + if (inside && !(named && !named.some((n) => writtenIn(n, ref)))) return null; + let view = this.classExpressionFreeViews.get(base); + if (!view) { + view = this.classExpressionFreeView(base); + this.classExpressionFreeViews.set(base, view); + this.classExpressionFreeBases.set(view, base); + } + return view; + } + + /** + * `base` without the classes class expressions declare and their members. + * The lookups an import reads a module's exports by (getNodesInFileNamed, + * getExportedNodesInFile) are left whole. + */ + private classExpressionFreeView(base: ResolutionContext): ResolutionContext { + const { hiddenIds } = this.classExpressions()!; + const hidden = (n: Node): boolean => hiddenIds.has(n.id); + const keep = (nodes: Node[]): Node[] => (nodes.some(hidden) ? nodes.filter((n) => !hidden(n)) : nodes); + const byKind = new Map(); + const getMethodMatches = base.getMethodMatches; + const iterateNodesByKind = base.iterateNodesByKind; + const { files } = this.classExpressions()!; + return { + ...base, + getNodesByName: (name) => keep(base.getNodesByName(name)), + getNodesByLowerName: (lowerName) => keep(base.getNodesByLowerName(lowerName)), + getNodesByQualifiedName: (qualifiedName) => keep(base.getNodesByQualifiedName(qualifiedName)), + getNodesInFile: (filePath) => (files.has(filePath) ? keep(base.getNodesInFile(filePath)) : base.getNodesInFile(filePath)), + getNodesByKind: (kind) => { + let nodes = byKind.get(kind); + if (!nodes) byKind.set(kind, (nodes = keep(base.getNodesByKind(kind)))); + return nodes; + }, + getMethodMatches: getMethodMatches + ? (typeName, methodName, language) => keep(getMethodMatches(typeName, methodName, language)) + : undefined, + iterateNodesByKind: iterateNodesByKind + ? function* (kind) { + for (const n of iterateNodesByKind(kind)) if (!hidden(n)) yield n; + } + : undefined, + }; + } + + /** classExpressionFreeContextFor's views, by the context each hides from and what it hides, and each view's context; clearCaches drops both. */ + private classExpressionFreeViews = new WeakMap(); + private classExpressionFreeBases = new WeakMap(); + /** + * The names that only class expressions' classes and members carry, which + * name nothing where classExpressionFreeView hides those: lit's DOM shims + * (`class HTMLElement`, `class Event`) would otherwise send every + * `HTMLElement` and `new Event()` across the project through the whole + * resolution only to find nothing, where before the name pre-filter + * dropped them. Read once per cache window. + */ + private classExpressionOnlyNames(): Set { + if (this.classExpressionOnlyNameSet) return this.classExpressionOnlyNameSet; + const only = new Set(); + const expressions = this.classExpressions(); + if (expressions) { + const names = new Set(); + for (const id of expressions.hiddenIds) { + const n = this.nodeById(id); + if (n) names.add(n.name); + } + for (const name of names) { + if (!this.queries.getNodesByName(name).some((n) => !expressions.hiddenIds.has(n.id))) only.add(name); + } + } + this.classExpressionOnlyNameSet = only; + return only; + } + + /** classExpressionOnlyNames' answer; clearCaches drops it. */ + private classExpressionOnlyNameSet: Set | null = null; + + /** + * The classes named class expressions declare: by name, the files holding + * them, the ids of them and their members, and the one a member belongs + * to. Null when there are none. Read once per cache window. A class a + * CommonJS module exports is left out: the module binds it, so it is read + * as a declaration is. + */ + private classExpressionIndex: ClassExpressionIndex | null | undefined; + + private classExpressions(): ClassExpressionIndex | null { + if (this.classExpressionIndex !== undefined) return this.classExpressionIndex; + const byName = new Map(); + const byFile = new Map(); + const byKey = new Map(); + const files = new Set(); + const hiddenIds = new Set(); + const linesOf = (file: string): readonly string[] => + this.context.getFileLines?.(file) ?? this.context.readFile(file)?.split(/\r?\n/) ?? []; + for (const n of this.queries.getNodesByKind('class')) { + if (!isJsClassExpression(n) || isCommonJsExportedClass(n, linesOf(n.filePath))) continue; + const named = byName.get(n.name); + if (named) named.push(n); else byName.set(n.name, [n]); + const inFile = byFile.get(n.filePath); + if (inFile) inFile.push(n); else byFile.set(n.filePath, [n]); + byKey.set(`${n.filePath}\0${n.qualifiedName}`, n); + files.add(n.filePath); + hiddenIds.add(n.id); + } + // A member is its class's when it is declared in its file under its + // qualified name (`Widget::run` of `Widget`). + const ownerOf = (member: Node): Node | undefined => { + const cut = member.qualifiedName.lastIndexOf('::'); + return cut > 0 ? byKey.get(`${member.filePath}\0${member.qualifiedName.slice(0, cut)}`) : undefined; + }; + if (files.size > 0) { + for (const n of this.queries.getNodesByFiles([...files])) { + if ((n.kind === 'method' || n.kind === 'property' || n.kind === 'field') && ownerOf(n)) hiddenIds.add(n.id); + } + } + this.classExpressionIndex = byName.size > 0 ? { byName, byFile, files, hiddenIds, ownerOf } : null; + return this.classExpressionIndex; + } + /** Is `filePath` (project-relative) one of the files the index holds? */ private isIndexedFile(filePath: string): boolean { const normalized = filePath.replace(/\\/g, '/'); diff --git a/src/resolution/js-class-expressions.ts b/src/resolution/js-class-expressions.ts new file mode 100644 index 0000000000..1f7ad26909 --- /dev/null +++ b/src/resolution/js-class-expressions.ts @@ -0,0 +1,66 @@ +/** + * A named class expression's name is bound inside the expression alone + * (#2499): `register(class Gadget {…})` declares no `Gadget` for the code + * around it, where `new Gadget()` throws a ReferenceError. The extractor gives + * such a class a node, so its members are its own and its `extends` is + * known, and marks it with its head as its signature (`class Gadget`), which + * a class declaration doesn't carry. + * + * The class's name is bound nowhere outside the expression, and its value + * travels in ways resolution doesn't follow, except an import of what a + * module exports. So the resolver reads a reference outside the class + * expressions without their classes and members (ReferenceResolver's + * classExpressionFreeContextFor): there the reference means what it meant + * before those classes were indexed. A binding of the same name around an + * expression, like the `Panel` of `const Panel = class Panel {…}`, keeps the + * name, at the confidence it had. A class a CommonJS module exports, + * `module.exports = class Router {…}`, is the module's value, which every + * importer binds, so it isn't scoped: it is read as a declaration is. + */ +import type { Node } from '../types'; +import type { UnresolvedRef } from './types'; + +/** Languages a class expression's class and the references to its name can be written in: TS/JS, and the scripts of Vue, Svelte and Astro files. */ +export const CLASS_EXPRESSION_REF_LANGUAGES: ReadonlySet = new Set(['typescript', 'tsx', 'javascript', 'jsx', 'arkts', 'vue', 'svelte', 'astro']); + +/** What the resolver knows of the classes named class expressions declare (ReferenceResolver.classExpressions). */ +export interface ClassExpressionIndex { + /** The classes, by name. */ + byName: Map; + /** The classes, by the file that holds them. */ + byFile: Map; + /** The files that hold them. */ + files: Set; + /** The ids of the classes and of their members. */ + hiddenIds: Set; + /** The class a member belongs to, when it is one of them. */ + ownerOf: (member: Node) => Node | undefined; +} + +/** Whether `node` is a class a named class expression declares. */ +export function isJsClassExpression(node: Node): boolean { + return node.kind === 'class' && !!node.signature && CLASS_EXPRESSION_REF_LANGUAGES.has(node.language); +} + +/** The end of the text before a class a CommonJS module exports: `module.exports = `, `exports.Router = `, `module.exports.Router = `. */ +const COMMONJS_EXPORT_BEFORE = /(?:^|[^\w$.])(?:module\.)?exports(?:\.[A-Za-z_$][\w$]*)?\s*=\s*$/; + +/** + * Whether a class expression is what its CommonJS module exports, as in + * `module.exports = class Router {…}` or `exports.Router = class Router {…}` + * (`lines`: its file's lines). An importer reaches that class, its statics + * and, through an instance, its methods, as it reaches a declaration's. + */ +export function isCommonJsExportedClass(node: Node, lines: readonly string[]): boolean { + const line = lines[node.startLine - 1]; + if (line === undefined) return false; + return COMMONJS_EXPORT_BEFORE.test(`${lines[node.startLine - 2] ?? ''}\n${line.slice(0, node.startColumn)}`); +} + +/** Whether the reference is written inside `node`'s source range. */ +export function writtenIn(node: Node, ref: UnresolvedRef): boolean { + if (node.filePath !== ref.filePath) return false; + const afterStart = ref.line > node.startLine || (ref.line === node.startLine && ref.column >= node.startColumn); + const beforeEnd = ref.line < node.endLine || (ref.line === node.endLine && ref.column <= node.endColumn); + return afterStart && beforeEnd; +}