diff --git a/CHANGELOG.md b/CHANGELOG.md index d4189422..9aaca65a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -206,6 +206,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In Go, a struct that embeds another is no longer treated as overriding the methods it redeclares. gin's `Engine` embeds `RouterGroup` and has a `Use` of its own, but calling `Use` on a `RouterGroup` only ever runs `RouterGroup.Use`; before, `codegraph_explore`'s call path and impact went on from it into `Engine.Use` and the code only the engine runs. The same held for every struct or defined type embedded in another that redeclares one of its methods, like etcd's `Mutex` inside `lockerMutex`. A call through an interface still reaches each type that implements it, the embedding struct included. Re-index Go projects after upgrading. - In JavaScript and TypeScript, a subclass's `static` method no longer counts as overriding an instance method of the same name in its base class, or the reverse, since calling one never runs the other. In webpack, `codegraph_explore`'s call path and impact went on from `Module`'s `deserialize(context)` into the `static deserialize(context)` of `NormalModule` and other modules. A static method still overrides its base's static method and an instance method its base's instance method, and JavaScript class members are now marked static, as TypeScript ones already were. Re-index JavaScript and TypeScript projects after upgrading. Thanks @inth3shadows for the report and @yang0228 for the pull request. (#2761) - In TypeScript and JavaScript, a class in a `.tsx` or `.jsx` file now links its base class's and interfaces' methods to its overrides, as a class in a `.ts` or `.js` file already did. `codegraph_explore`'s call path, callers and impact now follow a call through the base into the override, like outline's `BaseEmail.send()` reaching each email's `render()`. This also works across file types, such as an interface in a `.ts` file implemented in a `.tsx` one. Before, these classes had no override links at all. Re-index projects with `.tsx` or `.jsx` files after upgrading. +- In Ruby, a class method, `def self.build` or a `def` inside `class << self`, is now told apart from an instance method of the same name. A call on the class, like `Widget.build`, links to the class method; a call on an instance, like `widget.build` after `widget = Widget.new`, links to the instance method; and a call without a receiver links to the one that matches the method it is written in. Before, both were stored as instance methods under one name, so a call linked to whichever the class declared first: in a service object with `def self.call` and `def call`, `card.call` on an instance linked to the class method. Re-index Ruby projects after upgrading. - 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) diff --git a/__tests__/kernel-ruby-parity.test.ts b/__tests__/kernel-ruby-parity.test.ts index 9dcf00c5..72a25297 100644 --- a/__tests__/kernel-ruby-parity.test.ts +++ b/__tests__/kernel-ruby-parity.test.ts @@ -148,6 +148,51 @@ describe.skipIf(!kernelBuilt)('kernel Ruby extraction parity', () => { ]); }); + // A class method (`def self.x`, `def Widget.x`, a `def` in `class << self`) + // is static on both backends, every other def is not: they share a + // qualified name with any instance method of the name. + it('class methods are static', () => { + const src = [ + 'class Widget', + ' def build = self', + ' def self.build(opts) = new', + ' def Widget.named = 1', + ' class << self', + ' def make = new', + ' def other', + ' def nested = 2', + ' end', + ' end', + ' def run', + ' def inner = 3', + ' end', + 'end', + 'def self.top = 4', + 'def plain = 5', + '', + ].join('\n'); + assertParity('src/widget.rb', src); + process.env.CODEGRAPH_KERNEL_LANGS = 'all'; + delete process.env.CODEGRAPH_KERNEL; + const viaKernel = tryKernelExtract('src/widget.rb', src, 'ruby'); + const statics = viaKernel!.nodes + .filter((n) => n.kind === 'method' || n.kind === 'function') + .map((n) => `${n.kind} ${n.name}:${n.startLine} ${n.isStatic === true ? 'static' : n.isStatic === false ? 'instance' : 'unset'}`) + .sort(); + expect(statics).toEqual([ + 'function inner:12 instance', + 'function nested:8 instance', + 'function plain:16 instance', + 'function top:15 static', + 'method build:2 instance', + 'method build:3 static', + 'method make:6 static', + 'method named:4 static', + 'method other:7 static', + 'method run:11 instance', + ]); + }); + it('mixin implements refs carry filePath through the v2 ref-flag wire path', () => { const src = 'class Widget\n include Comparable\nend\n'; process.env.CODEGRAPH_KERNEL_LANGS = 'all'; diff --git a/__tests__/ruby-singleton-methods.test.ts b/__tests__/ruby-singleton-methods.test.ts new file mode 100644 index 00000000..5307b35a --- /dev/null +++ b/__tests__/ruby-singleton-methods.test.ts @@ -0,0 +1,212 @@ +/** + * A Ruby class method — `def self.build`, `def Widget.build`, or a `def` + * inside `class << self` — is the class's own, not one its instances answer + * to. They were indexed as instance methods under the same qualified name as + * an instance method of that name (`Widget::build` twice), so a call picked + * whichever the class declared first: `Widget.build` could land on + * `def build`, and `widget.build` on `def self.build`. Class methods are now + * marked static, and a call reaches the one of the right kind: a call on the + * class (`Widget.build`, `Billing::Payout.call`) its class method, a call on + * an instance (`w = Widget.new; w.build`) its instance method, and a bare + * call the one of the calling method's kind. Only such pairs are split: a + * block may run on another object, so a bare call with no same-named method + * of the other kind keeps the target it had. A receiver known by its name + * alone (`widget.build`) may hold the class itself, so it splits nothing. + * + * Runs on the native kernel (skipped when none is built) and the wasm + * extractor. + */ +import { describe, it, expect, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; +import { getKernel } from '../src/extraction/kernel/loader'; + +const FILES: Record = { + // The instance method first, so declaration order alone picks it. + 'app/widget.rb': `class Widget + def build + self + end + + def self.build(opts) + new + end + + class << self + def make + new + end + + def build_all + build(1) + end + end + + def run + build + Widget.build(1) + end + + def self.go + build(2) + end +end +`, + 'app/user.rb': `class User + def x + w = Widget.new + w.build + Widget.build(3) + Widget.make + end +end +`, + // #2503's control: an instance of a class whose class method comes first. + 'app/services/billing/charge_card.rb': `module Billing + class ChargeCard + def self.call(**kw) = new(**kw).call + def initialize(amount:) = @amount = amount + def call = @amount + end +end +`, + // A class method's block (a test DSL's) calls what an instance method and a + // top-level function are both named: no pair of one class, so no split. + 'app/spec_like.rb': `class SpecLike + def self.parses + run_case do + mock_app + end + end + + def mock_app = 1 +end +`, + 'app/helpers.rb': `def mock_app = 2 +`, + // A receiver named after a class that holds classes, as rubocop's + // `cop.cop_name` over its registry's cop classes does. + 'app/cop.rb': `class Cop + def self.cop_name = 'Style/Foo' + def cop_name = self.class.cop_name +end +`, + 'app/registry.rb': `class Registry + def names(cops) + cops.map { |cop| cop.cop_name } + end +end +`, + // A namespaced class whose instance method comes first. + 'app/services/billing/payout.rb': `module Billing + class Payout + def call = 1 + def self.call(**kw) = new.call + end +end +`, + 'app/services/checkout/start.rb': `module Checkout + class Start + def call + card = Billing::ChargeCard.new(amount: 3) + card.call + Billing::Payout.call(amount: 4) + payout = Billing::Payout.new + payout.call + end + end +end +`, +}; + +let root = ''; +let cg: CodeGraph | undefined; +let savedKernel: string | undefined; + +afterEach(() => { + cg?.close(); + cg = undefined; + if (root) fs.rmSync(root, { recursive: true, force: true }); + root = ''; + if (savedKernel === undefined) delete process.env.CODEGRAPH_KERNEL; + else process.env.CODEGRAPH_KERNEL = savedKernel; +}); + +async function index(backend: string): Promise { + savedKernel = process.env.CODEGRAPH_KERNEL; + if (backend === 'wasm') process.env.CODEGRAPH_KERNEL = '0'; + else delete process.env.CODEGRAPH_KERNEL; + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-ruby-singleton-')); + 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 }); + return cg; +} + +/** `:` of the method each `calls` edge written on `line` of `file` reaches. */ +function callsAt(graph: CodeGraph, file: string, line: number): string[] { + const ids = graph.getNodesInFile(file).map((n) => n.id); + return graph.getOutgoingEdgesFrom(ids) + .filter((e) => e.kind === 'calls' && e.line === line) + .map((e) => { const t = graph.getNode(e.target)!; return `${t.qualifiedName}:${t.startLine}`; }) + .sort(); +} + +const backends = [ + ['kernel', getKernel() === null], + ['wasm', false], +] as const; + +describe.each(backends)('Ruby class methods (%s)', (backend, skip) => { + it.skipIf(skip)('are static, and instance methods are not', async () => { + const graph = await index(backend); + const statics = graph.getNodesInFile('app/widget.rb') + .filter((n) => n.kind === 'method') + .map((n) => `${n.name}:${n.startLine}:${n.isStatic === true ? 'static' : 'instance'}`) + .sort(); + expect(statics).toEqual([ + 'build:2:instance', + 'build:6:static', + 'build_all:15:static', + 'go:25:static', + 'make:11:static', + 'run:20:instance', + ]); + }); + + it.skipIf(skip)('a call on the class reaches its class method', async () => { + const graph = await index(backend); + expect(callsAt(graph, 'app/user.rb', 5)).toEqual(['Widget::build:6']); + expect(callsAt(graph, 'app/user.rb', 6)).toEqual(['Widget::make:11']); + expect(callsAt(graph, 'app/widget.rb', 22)).toEqual(['Widget::build:6']); + expect(callsAt(graph, 'app/services/checkout/start.rb', 6)).toEqual(['Billing::Payout::call:4']); + }); + + it.skipIf(skip)('a call on an instance reaches its instance method', async () => { + const graph = await index(backend); + expect(callsAt(graph, 'app/user.rb', 4)).toEqual(['Widget::build:2']); + expect(callsAt(graph, 'app/services/checkout/start.rb', 5)).toEqual(['Billing::ChargeCard::call:5']); + expect(callsAt(graph, 'app/services/checkout/start.rb', 8)).toEqual(['Billing::Payout::call:3']); + }); + + it.skipIf(skip)('a bare call reaches the method of the calling method kind', async () => { + const graph = await index(backend); + expect(callsAt(graph, 'app/widget.rb', 21)).toEqual(['Widget::build:2']); + expect(callsAt(graph, 'app/widget.rb', 26)).toEqual(['Widget::build:6']); + expect(callsAt(graph, 'app/widget.rb', 16)).toEqual(['Widget::build:6']); + }); + + it.skipIf(skip)('a receiver known by its name alone splits nothing: it may hold the class', async () => { + const graph = await index(backend); + expect(callsAt(graph, 'app/registry.rb', 3)).toEqual(['Cop::cop_name:2']); + }); + + it.skipIf(skip)('a bare call with no method of the other kind by its name keeps its target', async () => { + const graph = await index(backend); + expect(callsAt(graph, 'app/spec_like.rb', 4)).toEqual(['SpecLike::mock_app:8']); + }); +}); diff --git a/codegraph-kernel/src/ruby.rs b/codegraph-kernel/src/ruby.rs index 101a3271..96e004e5 100644 --- a/codegraph-kernel/src/ruby.rs +++ b/codegraph-kernel/src/ruby.rs @@ -17,7 +17,7 @@ use crate::buffers::{ build_meta, edge_kind_index, node_kind_index, Arena, BoolFlags, EdgeRow, EmitOut, NodeRow, - RefRow, StrRef, Tables, FLAG_IS_EXPORTED, FUNCTION_REF_CODE, NONE, NONE_STR, + RefRow, StrRef, Tables, FLAG_IS_EXPORTED, FLAG_IS_STATIC, FUNCTION_REF_CODE, NONE, NONE_STR, REF_FLAG_FILE_PATH, }; use crate::docstring::preceding_docstring; @@ -97,6 +97,9 @@ struct Extra { docstring: Option, signature: Option, visibility: Option, + /// The isStatic hook (languages/ruby.ts): set on every def, true for a + /// class method (is_class_method). + is_static: Option, } struct ValueScope<'t> { @@ -283,10 +286,15 @@ impl<'t> Walker<'t> { let id_ref = self.arena.put(&id); let doc_ref = opt_str(&mut self.arena, extra.docstring.as_deref()); let sig_ref = opt_str(&mut self.arena, extra.signature.as_deref()); + // No isExported/isAsync hooks; isStatic only for defs. + let mut flags = BoolFlags::default(); + if let Some(v) = extra.is_static { + flags.set(FLAG_IS_STATIC, v); + } let row = self.tables.push_node(&NodeRow { kind: node_kind_index(kind).unwrap(), visibility: extra.visibility.unwrap_or(0), - flags: BoolFlags::default(), // no isExported/isAsync/isStatic hooks + flags, start_line, end_line, start_column: self.col_of(node), @@ -615,6 +623,7 @@ impl<'t> Walker<'t> { docstring: preceding_docstring(node, self.src), signature: None, // no getSignature hook visibility: Some(self.visibility_of(node)), + is_static: Some(is_class_method(node)), }; let Some(row) = self.create_node("function", &name, node, extra) else { return }; // (ruby ∉ TYPE_ANNOTATION_LANGUAGES; decorators are a structural no-op) @@ -633,6 +642,7 @@ impl<'t> Walker<'t> { docstring: preceding_docstring(node, self.src), signature: None, visibility: Some(self.visibility_of(node)), + is_static: Some(is_class_method(node)), }; let Some(row) = self.create_node("method", &name, node, extra) else { return }; self.stack.push(Scope { row, kind: "method", name }); @@ -649,6 +659,7 @@ impl<'t> Walker<'t> { docstring: preceding_docstring(node, self.src), signature: None, visibility: Some(self.visibility_of(node)), + ..Extra::default() }; let Some(row) = self.create_node("class", &name, node, extra) else { return }; @@ -692,7 +703,7 @@ impl<'t> Walker<'t> { } let name = self.text(left).to_string(); let signature = right.map(|r| util::init_signature(self.text(r))); - self.create_node("variable", &name, node, Extra { docstring, signature, visibility: None }); + self.create_node("variable", &name, node, Extra { docstring, signature, ..Extra::default() }); } /// extractImport — every non-body `call` lands here (importTypes:['call']). @@ -1136,6 +1147,22 @@ fn is_constant_path(node: Node) -> bool { } } +/// isRubyClassMethod (languages/ruby.ts): `def self.x` / `def Widget.x` (a +/// singleton_method), or a `def` inside `class << self`. +fn is_class_method(node: Node) -> bool { + match node.kind() { + "singleton_method" => true, + "method" => { + let mut parent = node.parent(); + if parent.map_or(false, |p| p.kind() == "body_statement") { + parent = parent.and_then(|p| p.parent()); + } + parent.map_or(false, |p| p.kind() == "singleton_class") + } + _ => false, + } +} + /// The Ruby `call` node whose argument_list (or keyword pair) contains `node` /// — nearest `call` ancestor within 4 parent hops (function-ref.ts:837). fn ruby_enclosing_call(node: Node) -> Option { diff --git a/docs/design/ruby-kernel-port-checklist.md b/docs/design/ruby-kernel-port-checklist.md index 120e40ec..26f1993e 100644 --- a/docs/design/ruby-kernel-port-checklist.md +++ b/docs/design/ruby-kernel-port-checklist.md @@ -253,14 +253,18 @@ Hooks PRESENT (port each exactly): (see extractImport below). Hooks ABSENT (the walker must NOT do these): `preParse`, `resolveName`, -`recoverMangledName`, `isMisparsedFunction`, `isConst`, `isStatic`, +`recoverMangledName`, `isMisparsedFunction`, `isConst`, `isExported`, `isAsync`, `getSignature`, `getReturnType`, `getReceiverType`, `resolveBody`, `classifyClassNode`, `classifyMethodNode`, `extractPropertyName`, `interfaceKind`, `extraClassNodeTypes`, `packageTypes`/`extractPackage`, `extractModifiers`, `synthesizeMembers`, `skipBodilessClass`, `methodsAreTopLevel`. Consequences: every ruby -function/method node has signature/isAsync/isStatic/returnType/isExported -**undefined** (file node isExported:false); **no isConst means every +function/method node has signature/isAsync/returnType/isExported +**undefined** (file node isExported:false). `isStatic` IS a hook +(isRubyClassMethod / `is_class_method`): every function/method node carries +it — true for a `singleton_method` (`def self.x`, `def Foo.x`) and for a +`method` whose parent, past one `body_statement`, is a `singleton_class` +(`class << self`), false for every other def (a nested def included). **no isConst means every `assignment` extracts kind `'variable'`, never `'constant'` — including `MAX = 3`** (value-ref targeting still works: kind variable is a target); `attr_accessor`/`attr_reader`/`attr_writer` synthesize NOTHING (no @@ -279,9 +283,9 @@ non-decorator sibling — always a no-op). | every node | visitNode hook first (943) | mixin calls + modules handled there (above); handled → scanFnRefSubtree + STOP | | every node | maybeCaptureFnRefs (990) | fires for `argument_list` / `pair` (the RUBY_SPEC dispatch keys) in visitNode context too — this is how class-body hook-DSL symbols are captured | | `method` | functionTypes:994 | inside class-like (class/module/…, isInsideClassLikeNode:1486 — **`module` counts**, 1498) AND in methodTypes → extractMethod:1737; else extractFunction:1517. skipChildren | -| `singleton_method` (`def self.x` / `def Foo.x` / `def obj.x`) | NOT functionTypes → methodTypes:1027 | no classifyMethodNode → extractMethod. At top level: not class-like, no methodsAreTopLevel, no receiver hook → gate 1747 sends it to extractFunction → a top-level `def self.x` is a plain **function** node named x. `object` field ignored everywhere — `def self.x` vs `def x` are indistinguishable in the graph (isStatic undefined) | +| `singleton_method` (`def self.x` / `def Foo.x` / `def obj.x`) | NOT functionTypes → methodTypes:1027 | no classifyMethodNode → extractMethod. At top level: not class-like, no methodsAreTopLevel, no receiver hook → gate 1747 sends it to extractFunction → a top-level `def self.x` is a plain **function** node named x. `object` field ignored everywhere; `def self.x` and `def x` share a qualified name, and only isStatic (true for the singleton_method) tells them apart | | `class` | classTypes:1005 | no classifyClassNode → extractClass:1679 (kind 'class') | -| `singleton_class` (`class << self`) | NO branch | recursed → its `body` body_statement's defs extract as methods of the OUTER class, indistinguishable from instance methods (probed) | +| `singleton_class` (`class << self`) | NO branch | recursed → its `body` body_statement's defs extract as methods of the OUTER class, under the same qualified names as instance methods, with isStatic true (probed) | | `assignment` (top level / class / module) | variableTypes:1098 | gate: `!isInsideClassLikeNode() \|\| isClassScopeConstantAssignment` (1508: type==='assignment' AND (`left` field ?? namedChild(0)).type === `'constant'`). File scope: identifier AND constant LHS both extract. Class/module scope: ONLY constant-LHS. Then extractVariable:2538 + scanFnRefSubtree (1110) + skipChildren — **the RHS is never walked: top-level/class-level `X = Foo.new` emits NO instantiates/calls** | | `operator_assignment` (`+=`, `\|\|=`) | no branch | not `assignment` → nothing extracted at any scope; recursed (children emit nothing either at non-body scope) | | `call` (top level / class / module body) | **importTypes:1209 — NEVER callTypes** | extractImport:3170 (below). require/require_relative → import node + refs; **every other call → NOTHING** (hook null → 3350 `if (this.extractor.extractImport) return;`). skipChildren stays false → children ARE visited: nested calls also land here (nothing), argument_lists get fn-ref capture. This kills `attr_accessor`, `has_many`, `define_method` (+ its do_block body!), `get '/x' do…end` route blocks, bare DSL calls — **all invisible to extraction at non-body scope** except fn-ref candidates + the rails regex extractor | @@ -330,7 +334,7 @@ extractFilePackage:1397 (no packageTypes → no namespace node); `value=` — keep the suffix in the name). `` path (1549) can't trigger (grammar requires a name). No misparse hook. Node extras: docstring (getPrecedingDocstring), signature undefined, visibility (sibling scan!), - isExported/isAsync/isStatic/returnType undefined. extractTypeAnnotations → + isExported/isAsync/returnType undefined, isStatic from the hook (above). extractTypeAnnotations → no-op (ruby ∉ TYPE_ANNOTATION_LANGUAGES:5752). extractDecoratorsFor → no-op. Push, walk `body` field (`body_statement`) via visitFunctionBody, pop. **`parameters` (method_parameters) are NEVER walked — a call in a diff --git a/src/extraction/languages/ruby.ts b/src/extraction/languages/ruby.ts index 8679a15c..f053fa81 100644 --- a/src/extraction/languages/ruby.ts +++ b/src/extraction/languages/ruby.ts @@ -2,6 +2,20 @@ import type { Node as SyntaxNode } from 'web-tree-sitter'; import { getNodeText, getChildByField } from '../tree-sitter-helpers'; import type { LanguageExtractor } from '../tree-sitter-types'; +/** + * A class method: `def self.x` or `def Widget.x` (a singleton_method), or a + * `def` inside `class << self`. It shares its qualified name with an instance + * method of the same name (`Widget::build` for both), so only this tells the + * two apart. + */ +function isRubyClassMethod(node: SyntaxNode): boolean { + if (node.type === 'singleton_method') return true; + if (node.type !== 'method') return false; + let parent = node.parent; + if (parent?.type === 'body_statement') parent = parent.parent; + return parent?.type === 'singleton_class'; +} + export const rubyExtractor: LanguageExtractor = { functionTypes: ['method'], classTypes: ['class'], @@ -103,6 +117,7 @@ export const rubyExtractor: LanguageExtractor = { return name; }, + isStatic: isRubyClassMethod, getVisibility: (node) => { // Ruby visibility is based on preceding visibility modifiers let sibling = node.previousNamedSibling; diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 55fbe560..122dece6 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -7830,6 +7830,33 @@ function isRubyMethodInScope(method: Node, ref: UnresolvedRef, context: Resoluti return rubyAncestry(here.qualifiedName, context).has(method.qualifiedName.slice(0, cut)); } +/** + * Of a Ruby class's two methods of one name, `def build` and `def self.build` + * (both `Widget::build`), the one a bare call means: a bare call is a call on + * `self`, so in an instance method it is the instance method, and in a class + * method (`def self.x`, `class << self`) the class method. Widget's `run` + * calling `build` reaches `def build`, and its `def self.go` calling `build` + * reaches `def self.build`. Only such pairs are split: inside a block that + * runs on another object (`Module.new do … end`, a test's `it do … end`) the + * caller's kind says nothing of `self`, and any other candidate stays as it + * was. A call written anywhere but in a method splits nothing. + */ +function rubyMethodsOfCallerKind(candidates: Node[], ref: UnresolvedRef, context: ResolutionContext): Node[] { + if (candidates.length < 2) return candidates; + const caller = context.getNodeById?.(ref.fromNodeId); + if (!caller || caller.kind !== 'method' || caller.language !== 'ruby') return candidates; + const wantClass = caller.isStatic === true; + const isRubyMethod = (n: Node): boolean => n.kind === 'method' && n.language === 'ruby'; + // Per qualified name, which kinds the candidates declare: 1 class, 2 instance. + const kinds = new Map(); + for (const n of candidates) { + if (isRubyMethod(n)) kinds.set(n.qualifiedName, (kinds.get(n.qualifiedName) ?? 0) | (n.isStatic === true ? 1 : 2)); + } + const twinOfOtherKind = (n: Node): boolean => + isRubyMethod(n) && kinds.get(n.qualifiedName) === 3 && (n.isStatic === true) !== wantClass; + return candidates.some(twinOfOtherKind) ? candidates.filter((n) => !twinOfOtherKind(n)) : candidates; +} + /** A Ruby class's qualified name, those of its superclasses, and of every module mixed into any of them. */ export function rubyAncestry(qn: string, context: ResolutionContext): Set { let memo = RUBY_ANCESTRY.get(context); @@ -11950,7 +11977,8 @@ export function matchByExactName( const ranked = dartBare ? nearestDartMembers(preferOwnDartLibrary(filtered, ref, context), ref, context) : dartTypeRef && dartPrefix === null ? preferOwnDartLibrary(filtered, ref, context) : swiftShape && swiftShape.shape !== 'chained' ? nearestSwiftMembers(filtered, ref, context) - : kotlinBare ? lexicalKotlinMembers(filtered, ref, context) : cppSpecializationPool(filtered, ref, context); + : kotlinBare ? lexicalKotlinMembers(filtered, ref, context) + : rubyBare ? rubyMethodsOfCallerKind(filtered, ref, context) : cppSpecializationPool(filtered, ref, context); // (Above the ceiling below nothing is picked, so no translation unit is read.) const candidates = ranked.length <= AMBIGUOUS_NAME_CEILING ? withinCppTranslationUnit(ranked, ref, context, true) : ranked; @@ -13125,7 +13153,11 @@ export function resolveMethodOnType( // intentionally in ANOTHER file (#314) — is unaffected: those blocks return // early whenever an import pins the class. if (ref.referenceKind === 'function_ref' && matches.length !== 1) return null; - const ordered = preferCallSiteFile(withinCppTranslationUnit(cppVisibleFirst(matches, ref, context), ref, context), ref.filePath); + // A Ruby receiver is typed only by `x = Widget.new`, so it is an instance: + // `x.build` reaches `def build`, not `def self.build`, wherever each is. + const ordered = rubyMethodsOfKindFirst( + preferCallSiteFile(withinCppTranslationUnit(cppVisibleFirst(matches, ref, context), ref, context), ref.filePath), + ref.language === 'ruby' ? 'instance' : undefined); return { original: ref, targetNodeId: ordered[0]!.id, @@ -18609,18 +18641,18 @@ function tsClassHead(lines: readonly string[], cls: Node): string { } /** A method named `name` on a supertype of the given classes, nearest first. */ -function inheritedClassMethod(classes: Node[], name: string, context: ResolutionContext): Node | null { +function inheritedClassMethod(classes: Node[], name: string, context: ResolutionContext, kind?: RubyMethodKind): Node | null { if (classes.length === 0) return null; let memo = INHERITED_METHODS.get(context); if (!memo) INHERITED_METHODS.set(context, (memo = new Map())); - const key = `${classes.map((c) => c.id).join(',')}|${name}`; + const key = `${classes.map((c) => c.id).join(',')}|${name}|${kind ?? ''}`; if (memo.has(key)) return memo.get(key)!; - const found = findInheritedClassMethod(classes, name, context); + const found = findInheritedClassMethod(classes, name, context, kind); memo.set(key, found); return found; } -function findInheritedClassMethod(classes: Node[], name: string, context: ResolutionContext): Node | null { +function findInheritedClassMethod(classes: Node[], name: string, context: ResolutionContext, kind?: RubyMethodKind): Node | null { const seen = new Set(classes.map((c) => c.id)); let frontier = classes; for (let depth = 0; depth < 5 && frontier.length > 0; depth++) { @@ -18630,8 +18662,8 @@ function findInheritedClassMethod(classes: Node[], name: string, context: Resolu 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); + const method = rubyMethodsOfKindFirst(context.getNodesInFile(decl.filePath).filter((n) => n.kind === 'method' && n.name === name && + n.qualifiedName.slice(0, Math.max(0, n.qualifiedName.lastIndexOf('::'))).split(/::|\./).pop() === decl.name), kind)[0]; if (method) return method; next.push(decl); } @@ -20242,7 +20274,9 @@ export function matchMethodCall( return { original: ref, targetNodeId: method.id, confidence: own ? 0.85 : 0.8, resolvedBy: 'qualified-name' }; } - const methodNode = classStrategyMethod(classNode, methodName!, context); + // A Ruby call on a class is a call of its class method: `Widget.build` + // reaches `def self.build`, not `def build`, whichever comes first. + const methodNode = classStrategyMethod(classNode, methodName!, context, ref.language === 'ruby' ? 'class' : undefined); if (methodNode) { return { @@ -20255,7 +20289,8 @@ 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); + const inherited = inheritedClassMethod(classCandidates.filter((c) => c.language === ref.language), methodName!, context, + ref.language === 'ruby' ? 'class' : undefined); if (inherited) return { original: ref, targetNodeId: inherited.id, confidence: 0.8, resolvedBy: 'qualified-name' }; return null; }); @@ -20344,6 +20379,10 @@ export function matchMethodCall( if (!isLexicallyReachable(classNode, ref, context)) continue; if (productionCaller && isTestPath(classNode.filePath)) continue; + // A Ruby receiver named after a class may hold the class as well as an + // instance of it (rubocop's `cop.cop_name` runs over its registry's + // cop classes, so it is `def self.cop_name`), so neither of a class's + // two methods of the name is preferred: the first declared is taken. const methodNode = classStrategyMethod(classNode, methodName!, context); if (methodNode) { @@ -25688,9 +25727,12 @@ function tsFieldCall( * the caller's loop, so a nested `Outer::Logger` can take a top-level * `Logger.log()` (declared first, or the only one with the method). Choosing * among them needs each language's scope rules. + * + * `kind` picks between a Ruby class's two methods of the name, `def self.m` + * and `def m`, which share one qualified name (rubyMethodsOfKindFirst). */ -function findOwnMethod(nodesInFile: Node[], classNode: Node, methodName: string): Node | undefined { - const methods = nodesInFile.filter((n) => n.kind === 'method' && n.name === methodName); +function findOwnMethod(nodesInFile: Node[], classNode: Node, methodName: string, kind?: RubyMethodKind): Node | undefined { + const methods = rubyMethodsOfKindFirst(nodesInFile.filter((n) => n.kind === 'method' && n.name === methodName), kind); const exact = `${classNode.qualifiedName}::${methodName}`; const byName = `${classNode.name}::${methodName}`; return ( @@ -25706,9 +25748,30 @@ function findOwnMethod(nodesInFile: Node[], classNode: Node, methodName: string) * `Form` reaches `BaseForm::is_valid`, which the substring test this replaced * only found because `BaseForm` contains `Form`. */ -function classStrategyMethod(classNode: Node, methodName: string, context: ResolutionContext): Node | undefined { - return findOwnMethod(context.getNodesInFile(classNode.filePath), classNode, methodName) ?? - inheritedClassMethod([classNode], methodName, context) ?? undefined; +function classStrategyMethod(classNode: Node, methodName: string, context: ResolutionContext, kind?: RubyMethodKind): Node | undefined { + return findOwnMethod(context.getNodesInFile(classNode.filePath), classNode, methodName, kind) ?? + inheritedClassMethod([classNode], methodName, context, kind) ?? undefined; +} + +/** + * Which of a Ruby class's two methods of one name a call reaches: a call on + * the class (`Widget.build`) its class method, `def self.build` or one in + * `class << self`; a call on an instance (`widget.build`) its instance + * method, `def build`. Both share one qualified name, `Widget::build`, and + * only `isStatic` tells them apart. + */ +export type RubyMethodKind = 'class' | 'instance'; + +/** + * `methods` with the ones of `kind` first, in their own order: a pick of the + * first then takes one of the kind wherever there is one, and still the + * first otherwise. Methods of other languages are never static-split. + */ +export function rubyMethodsOfKindFirst(methods: T[], kind: RubyMethodKind | undefined): T[] { + if (!kind || methods.length < 2) return methods; + const fits = (m: T): boolean => m.language === 'ruby' && (m.isStatic === true) === (kind === 'class'); + const first = methods.filter(fits); + return first.length === 0 || first.length === methods.length ? methods : [...first, ...methods.filter((m) => !fits(m))]; } /** diff --git a/src/resolution/ruby-constant-paths.ts b/src/resolution/ruby-constant-paths.ts index b77e34fa..8d692591 100644 --- a/src/resolution/ruby-constant-paths.ts +++ b/src/resolution/ruby-constant-paths.ts @@ -20,7 +20,7 @@ import type { Node } from '../types'; import type { ResolutionContext, ResolvedRef, UnresolvedRef } from './types'; import { isTestPath } from '../search/query-utils'; -import { rubyAncestry } from './name-matcher'; +import { rubyAncestry, rubyMethodsOfKindFirst } from './name-matcher'; /** `A::B`, `::A`, `::A::B`: a constant path. Group 1 is a leading `::`. */ const CONSTANT_PATH = /^(::)?([A-Z]\w*(?:::[A-Z]\w*)*)$/; @@ -98,14 +98,16 @@ function lexicalScope(ref: UnresolvedRef, context: ResolutionContext): string { /** * The method `name` of the class or module `owner`: its own, else the first * of its superclasses and mixed-in modules that declares one (rubyAncestry). - * Production code never reaches one a test declares. + * Of a class's `def self.m` and `def m`, which share one qualified name, the + * call on the class reaches the class method. Production code never reaches + * one a test declares. */ function methodOf(owner: string, name: string, ref: UnresolvedRef, context: ResolutionContext): { node: Node; own: boolean } | null { const production = !isTestPath(ref.filePath); for (const qn of rubyAncestry(owner, context)) { const methods = context.getNodesByQualifiedName(`${qn}::${name}`).filter((n) => n.kind === 'method' && n.language === 'ruby' && !(production && isTestPath(n.filePath))); - if (methods.length > 0) return { node: ordered(methods, ref)[0]!, own: qn === owner }; + if (methods.length > 0) return { node: rubyMethodsOfKindFirst(ordered(methods, ref), 'class')[0]!, own: qn === owner }; } return null; }