From 788901090ec02e15b60036037a23cff97b60c33f Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Sat, 31 Oct 2026 23:19:49 -0500 Subject: [PATCH] fix(ruby): a call follows a class's real ancestors, in Ruby's order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since #1935 a receiver named after a class took what its supertypes declare, read by bare names: `class Human < ActiveRecord::Base` asked every class called `Base`, so rails' `human.name` reached Journey's `Node#name`, and `Topic.create` Digestor's `Node.create`. A value of a known class walked `extends` edges the same way, so `company.has_attribute?` took the `has_attribute?` of a `ClassMethods` module. And `class Mapper; include HttpHelpers` lost `mapper.get`, which 1.6.2 reached by a substring of the qualified name. The ancestors are now read as Ruby reads them: superclass and mixins found by constant lookup, a class's own body only, the module mixed in last asked first, and for an instance no module it `extend`s. - A receiver named after a class takes the class constant lookup finds, and past that class's own methods a word picks no namesake. - A call on a class reaches a superclass's `def self.`, what it `extend`s and the `ClassMethods` its concerns bring, never a mixin's instance method or a mixin's own `def self.`. - A bare name that is a method of the class around the call, taking no arguments and handing back what one class makes (`@jar ||= Jar.build(…)`), types the call on it, so `cookies.signed` reaches `ChainedCookieJars`. - A `?` or `!` method of Ruby's core classes needs a receiver named after the owner itself: sinatra's `bad_cookies.empty?` is no `Cookies::Jar#empty?`. Validation, base 4499d007 against this change (numbers in the PR): - rails-src 793d744c: 171,738 to 179,247 edges, 2,159 moved, 8,179 added, 670 lost. Added: Concern class methods (`Topic.find`), inherited instance methods (`topic.id`), helpers on bare calls (`concat`). Moved: to the first method in Ruby's order, off `Digestor::Node.create`. 86 sampled entries read: 0 false added. Lost: 442 `Node::name`-style false edges of the 670; about 30 were true by luck. - sinatra cb22afd7: 2 lost, both `bad_cookies.empty?`. mastodon (Ruby only) b0555670: 1 moved, 34 added, 1 lost. jekyll 97c3bdc8: same edges, 9 guesses now typed. Controls gin and flask byte-identical. - REG: rails-src 3 lost back; `company.has_attribute?` 14 of 14 on the instance method; rails-src/moved/24 (`table.foreign_key`, right by a substring) stays: it needs call-site flow, and the substring rule took 429 `error.message`. - Index CPU on rails-src, median of 5 interleaved: 74.9 to 73.2 s. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../ruby-receiver-class-ancestry.test.ts | 424 ++++++++++++++++++ src/resolution/index.ts | 3 +- src/resolution/name-matcher.ts | 252 +++++++++-- src/resolution/ruby-constant-paths.ts | 90 +++- 5 files changed, 743 insertions(+), 27 deletions(-) create mode 100644 __tests__/ruby-receiver-class-ancestry.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 296f39b9f..db1ada2da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -263,6 +263,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In Ruby, a call on a class written with its namespace, like `Billing::ChargeCard.call(…)` or `::Billing::ChargeCard.call(…)`, now links the caller to that class and to the class method it calls, as `ChargeCard.call(…)` already linked to the class. Before, a class used only through such calls looked like it had no callers. The name is looked up the way Ruby does it, from the namespaces around the call outward, so `Users::Create.call` reaches `Users::Create` and never a `Billing::Create`, a call inside `module Api; module V2` reaches `Api::V2`'s class before another version's, and a library's class like `ActiveRecord::Base` is never taken for a project class of the same last name. Re-index Ruby projects after upgrading. Thanks @james-jang for the report and @drakeo338 for the pull request. (#2503) - In Ruby, a call to a method whose name ends in `!` or `?` now links when its receiver's class is known, like `writer.flush!` or `writer.ready?` after `writer = CacheWriter.new`, or `CacheWriter.create!(…)` on the class. A method called straight on a new object, like `CacheWriter.new(key).write_cache` or `Billing::Ledger.new.charge!`, now links to that class's method or to one it inherits. Before, all of these linked nothing. The class is looked up the way Ruby does it, from the namespaces around the call outward, and one from outside your project, like rack's `Request` used inside `Rack::Protection`, links nothing rather than a project class of the same name. Re-index Ruby projects after upgrading. Thanks @ricobl. - In Ruby, a call on an instance variable, like `@writer.flush`, now links to the method of the class the variable holds when the class's `initialize` sets it to a new object of one class, like `@writer = CacheWriter.new`. Before, no call on an instance variable linked. A variable that `initialize` sets to anything else, or to objects of two classes, stays unlinked rather than guessed. Re-index Ruby projects after upgrading. +- In Ruby, a call now follows a class's real ancestors, in the order Ruby looks a method up: the modules it includes, the one included last first, found by Ruby's constant lookup. Before, a superclass written `ActiveRecord::Base` could mean any class named `Base` in the project, so `human.name` reached an unrelated `Node#name`. A call on a class now reaches the methods it `extend`s and the `ClassMethods` of the concerns it includes, like `Topic.find` or `Topic.create`; a call on a method of the same class that hands back a new object, like `cookies.signed`, reaches that object's class; and a `?` or `!` method of Ruby's core classes, like `bad_cookies.empty?`, no longer links to a project class by a shared word. - In PHP, a class written through a namespace alias in a different case than its `use` statement, like `new field\FirstName()` after `use App\Fields as Field;`, now links to that class, since PHP reads namespace names without regard to case. Before, the name was read relative to the file's own namespace and stayed unlinked. Re-index PHP projects after upgrading. Thanks @danusha2345. (#2369) - In PHP, a call on a variable now reaches the method its class really has. A declared type or a class the file can name is found through the file's namespace and `use` imports, group imports included, and its parents through the same names, past any attributes above the class and with a trait before the parent, so Drupal's `$entity->access()` no longer lands on a views plugin's `HandlerBase`, a `parent::__construct()` above a parent from outside the project no longer lands on an unrelated trait, and Laravel's `$this->events->dispatch()` reaches the events dispatcher rather than the bus. A Laravel facade now hands a static call to the class its `getFacadeAccessor()` names, and a local an Eloquent model's `create()` or `find()` gives is that model. - In Drupal, an import of a class whose short name another class shares, like `use Drupal\commerce_price\Price`, no longer also links to that other class. diff --git a/__tests__/ruby-receiver-class-ancestry.test.ts b/__tests__/ruby-receiver-class-ancestry.test.ts new file mode 100644 index 000000000..80e5999d3 --- /dev/null +++ b/__tests__/ruby-receiver-class-ancestry.test.ts @@ -0,0 +1,424 @@ +/** + * What a Ruby receiver reaches through its class's ancestors. They are read + * the way Ruby reads them: the superclass and the mixins found by constant + * lookup, the module mixed in last asked first, and a class's own class + * methods apart from what its instances answer. By bare names, `ActiveRecord::Base` + * was every `Base` in rails: `human.name` went to Journey's `Node#name` and + * `company.has_attribute?` to the `has_attribute?` of a `ClassMethods` module. + */ +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; + +const FILES: Record = { + // A class's methods from a nested module it includes. + 'lib/mapper.rb': `module ActionDispatch + module Routing + class Mapper + module HttpHelpers + def get(*paths) + paths + end + end + + module Base + def match(*paths) + paths + end + end + + include Base + include HttpHelpers + end + end +end +`, + 'test/routes_test.rb': `class RoutesTest + attr_reader :mapper + + def setup + @mapper = ActionDispatch::Routing::Mapper.new + end + + def test_get + mapper.get "/hello" + mapper.match "/hello" + end +end +`, + // A namesake of the superclass. + 'lib/journey.rb': `module ActionDispatch + module Journey + class Client + def get(path) + path + end + + def match(path) + path + end + end + + module Nodes + class Node + def name + "node" + end + end + + class Base < Node + end + end + end +end +`, + 'lib/active_record.rb': `module ActiveSupport + module Concern + end +end + +module ActiveRecord + module Persistence + extend ActiveSupport::Concern + + module ClassMethods + def create(attributes) + new + end + end + + def save + true + end + + def save! + true + end + end + + module Reflection + def self.create(macro, name) + macro + end + end + + module Transactions + extend ActiveSupport::Concern + + def save + super + end + end + + module Querying + def count_by_sql(sql) + sql + end + end + + module AttributeMethods + extend ActiveSupport::Concern + + module ClassMethods + def has_attribute?(attr_name) + true + end + end + + def has_attribute?(attr_name) + false + end + end + + module QueryCache + module ClassMethods + def cache + yield + end + end + end + + class Base + extend QueryCache::ClassMethods + extend Querying + include Persistence + include Reflection + include Transactions + include AttributeMethods + end +end +`, + 'test/models.rb': `class Human < ActiveRecord::Base +end + +class Company < ActiveRecord::Base +end + +class Holder + class Inner + include ActiveRecord::Transactions + end +end +`, + 'test/base_test.rb': `class BaseTest + def test_name + human = Human.new + human.name + end + + def test_named_receiver(human) + human.name + end + + def test_has_attribute + company = Company.new + company.has_attribute?("id") + end + + def test_mixed_in_last(company) + company.save + end + + def test_only_mixin(company) + company.save! + end + + def test_class_method + Company.create(name: "x") + Company.count_by_sql("x") + end + + def test_nested_mixin(holder) + holder.save + end +end +`, + // What a method of the class around the call hands back. + 'lib/jar.rb': `module ActionDispatch + class Cookies + module ChainedCookieJars + def signed + self + end + end + + class CookieJar + include ChainedCookieJars + + def self.build(request, cookies) + jar = new(request) + jar.update(cookies) + jar + end + + def delete(name) + name + end + end + end + + module TestProcess + def cookies + @cookie_jar ||= Cookies::CookieJar.build(@request, @request.cookies) + end + + def session + @request.session + end + + def fresh_jar + Cookies::CookieJar.new(@request) + end + + def open_for(request) + Cookies::CookieJar.new(request) + end + end +end + +class TestCase + include ActionDispatch::TestProcess +end + +class Mailbox + def delete(name) + name + end +end +`, + 'test/cookies_test.rb': `class CookiesTest < TestCase + class Session + def clear + end + end + + def test_signed + cookies.signed[:favorite] + cookies.delete(:favorite) + end + + def test_shadowed(cookies) + cookies.delete(:favorite) + end + + def test_block + [].each { |cookies| cookies.delete(:favorite) } + end + + def test_assigned + cookies = {} + cookies.delete(:favorite) + end + + def test_unmade + session.clear + end + + def test_fresh + fresh_jar.delete(:favorite) + end + + def test_argument + open_for.delete(:favorite) + end +end +`, + // A core class's method names. + 'lib/cookies.rb': `module Sinatra + module Cookies + class Jar + def empty? + true + end + end + end +end +`, + 'lib/tossing.rb': `class CookieTossing + def bad_cookies + @bad_cookies ||= [] + end + + def accepts? + bad_cookies.empty? + end +end +`, + 'lib/server.rb': `module IntegrationHelper + class BaseServer + def installed? + true + end + end +end +`, + 'lib/record.rb': `module Records + module ActiveRecord + module Persistence + def new_record? + true + end + end + end +end +`, + 'lib/checks.rb': `class Checks + def run(server, record) + server.installed? + record.new_record? + end +end +`, +}; + +beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-ruby-ancestry-')); + 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(); + fs.rmSync(root, { recursive: true, force: true }); +}); + +function callees(caller: string, ref: string): string[] { + const db = (cg as unknown as { db: { getDb(): { prepare(sql: string): { all(...a: unknown[]): Array<{ q: string; m: string | null }> } } } }).db; + const rows = db.getDb().prepare(` + SELECT t.qualified_name q, e.metadata m FROM edges e + JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target + WHERE e.kind = 'calls' AND s.qualified_name = ?`).all(caller); + return rows.filter((r) => JSON.parse(r.m ?? '{}').refName === ref).map((r) => r.q); +} + +describe('a Ruby receiver named after a class', () => { + it('reaches a module the class includes, however the module is written', () => { + expect(callees('RoutesTest::test_get', 'mapper.get')).toEqual(['ActionDispatch::Routing::Mapper::HttpHelpers::get']); + expect(callees('RoutesTest::test_get', 'mapper.match')).toEqual(['ActionDispatch::Routing::Mapper::Base::match']); + }); + + it('reaches no namesake of the superclass', () => { + expect(callees('BaseTest::test_name', 'human.name')).toEqual([]); + expect(callees('BaseTest::test_named_receiver', 'human.name')).toEqual([]); + }); + + it('asks the module mixed in last first', () => { + expect(callees('BaseTest::test_mixed_in_last', 'company.save')).toEqual(['ActiveRecord::Transactions::save']); + expect(callees('BaseTest::test_only_mixin', 'company.save!')).toEqual(['ActiveRecord::Persistence::save!']); + }); + + it('reads a class nested in another as that class alone', () => { + expect(callees('BaseTest::test_nested_mixin', 'holder.save')).toEqual([]); + }); +}); + +describe('a Ruby value of a known class', () => { + it('answers with an instance method, not one a ClassMethods module gives the class', () => { + expect(callees('BaseTest::test_has_attribute', 'company.has_attribute?')).toEqual(['ActiveRecord::AttributeMethods::has_attribute?']); + }); +}); + +describe('a call on a Ruby class', () => { + it('reaches what a Concern gives it, and what it extends', () => { + expect(callees('BaseTest::test_class_method', 'Company.create')).toEqual(['ActiveRecord::Persistence::ClassMethods::create']); + expect(callees('BaseTest::test_class_method', 'Company.count_by_sql')).toEqual(['ActiveRecord::Querying::count_by_sql']); + }); +}); + +describe('a bare Ruby name of a method that hands back one class', () => { + it('calls that class, through what its ancestry includes', () => { + expect(callees('CookiesTest::test_signed', 'cookies.signed')).toEqual(['ActionDispatch::Cookies::ChainedCookieJars::signed']); + expect(callees('CookiesTest::test_signed', 'cookies.delete')).toEqual(['ActionDispatch::Cookies::CookieJar::delete']); + expect(callees('CookiesTest::test_fresh', 'fresh_jar.delete')).toEqual(['ActionDispatch::Cookies::CookieJar::delete']); + }); + + it('says nothing of a name the calling method binds itself', () => { + expect(callees('CookiesTest::test_shadowed', 'cookies.delete')).toEqual([]); + expect(callees('CookiesTest::test_block', 'cookies.delete')).toEqual([]); + expect(callees('CookiesTest::test_assigned', 'cookies.delete')).toEqual([]); + }); + + it('says nothing of a method that takes arguments, or names no class', () => { + expect(callees('CookiesTest::test_argument', 'open_for.delete')).toEqual([]); + expect(callees('CookiesTest::test_unmade', 'session.clear')).toEqual(['CookiesTest::Session::clear']); + }); +}); + +describe('a Ruby core method name on a value', () => { + it('is no method of a class the receiver shares a module word with', () => { + expect(callees('CookieTossing::accepts?', 'bad_cookies.empty?')).toEqual([]); + }); + + it('leaves other predicates to the receiver word', () => { + expect(callees('Checks::run', 'server.installed?')).toEqual(['IntegrationHelper::BaseServer::installed?']); + expect(callees('Checks::run', 'record.new_record?')).toEqual(['Records::ActiveRecord::Persistence::new_record?']); + }); +}); diff --git a/src/resolution/index.ts b/src/resolution/index.ts index a4c4178d4..73ab65f20 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -41,7 +41,7 @@ import { clearAmbientModuleMemos, ambientModuleDeclarations } from './ambient-mo 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 { matchRubyConstantPathRef } from './ruby-constant-paths'; +import { clearRubyConstantPathMemos, matchRubyConstantPathRef } from './ruby-constant-paths'; import { resolveViaImport, resolvePhpImportedStaticCall, resolvePhpQualifiedClassRef, resolveTsQualifiedTypeRef, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef, isNixPathImportRef, isDartImportRef, isLuaRequireRef, isJsPathImportRef, isBoundToOutOfRepoImport, reachesAmbientModule, importBindingTail, clearImportResolverMemos, resolveImportPath, isExternalImport, pythonImportsInScope, ESM_IMPORT_LANGUAGES, jsModuleFile, pythonRootFingerprint, pythonReopenScope, pythonScriptModuleScope, pythonPackageImporters, moduleDefaultExport, isJsFamilyFile, isModuleBindingName } from './import-resolver'; import { ResolverPool, minRefsForPool, shouldEngageAdaptively } from './resolver-pool'; import { resolveAliasBinding } from './alias-binding'; @@ -681,6 +681,7 @@ export class ReferenceResolver { if (this.context) { clearImportResolverMemos(this.context); clearNameMatcherMemos(this.context); + clearRubyConstantPathMemos(this.context); clearCppMacroVisibility(this.context); clearSwiftTypeVisibility(this.context); clearDartLibraryMemos(this.context); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 5c9cd3190..c9efcc286 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -6669,10 +6669,30 @@ function stdMethodNames(language: string): ReadonlySet | null { case 'python': return PYTHON_STD_METHODS; case 'scala': return SCALA_STD_METHODS; case 'java': return JAVA_STD_METHODS; + case 'ruby': return RUBY_STD_METHODS; default: return null; } } +/** + * The `?` and `!` methods of Ruby's core classes. Nearly every receiver answers + * them, so the one project method of such a name says nothing about one: + * sinatra's `bad_cookies.empty?` on an Array is no `Cookies::Jar#empty?`. + */ +const RUBY_STD_METHODS: ReadonlySet = new Set([ + 'nil?', 'is_a?', 'kind_of?', 'instance_of?', 'respond_to?', 'frozen?', 'equal?', 'eql?', 'instance_variable_defined?', + 'between?', 'zero?', 'positive?', 'negative?', 'nonzero?', 'even?', 'odd?', 'finite?', 'infinite?', 'nan?', 'integer?', + 'empty?', 'any?', 'all?', 'none?', 'one?', 'include?', 'member?', 'key?', 'has_key?', 'value?', 'has_value?', 'cover?', + 'disjoint?', 'intersect?', 'subset?', 'superset?', 'proper_subset?', 'proper_superset?', + 'start_with?', 'end_with?', 'match?', 'ascii_only?', 'valid_encoding?', 'casecmp?', + 'eof?', 'closed?', 'tty?', 'exist?', 'file?', 'directory?', 'absolute?', 'relative?', 'alive?', 'lambda?', + 'blank?', 'present?', + 'merge!', 'map!', 'collect!', 'select!', 'filter!', 'reject!', 'compact!', 'flatten!', 'uniq!', 'sort!', 'sort_by!', + 'reverse!', 'rotate!', 'shuffle!', 'slice!', 'transform_keys!', 'transform_values!', 'gsub!', 'sub!', 'strip!', + 'lstrip!', 'rstrip!', 'chomp!', 'chop!', 'squeeze!', 'upcase!', 'downcase!', 'capitalize!', 'swapcase!', 'tr!', + 'delete!', 'next!', 'succ!', 'encode!', 'scrub!', 'delete_prefix!', 'delete_suffix!', +]); + /** Whether `name` is one of `language`'s standard-library method names (VB.NET's in any case). */ export function isStdMethodName(language: string, name: string): boolean { return stdMethodNames(language)?.has(language === 'vbnet' ? name.toLowerCase() : name) ?? false; @@ -6699,6 +6719,12 @@ const DART_STD_METHODS: ReadonlySet = new Set([ */ function receiverNamesOwner(receiver: string, method: Node, context: ResolutionContext): boolean { if (method.language === 'go') return namesOwnerType(receiver, method); + // A Ruby receiver, the class that owns the method, not one of the modules + // around it: `Sinatra::Cookies::Jar` is no `bad_cookies`. + if (method.language === 'ruby') { + const cut = method.qualifiedName.lastIndexOf('::'); + return cut > 0 && sharesReceiverWord(receiver, { ...method, qualifiedName: `${method.qualifiedName.slice(0, cut).split('::').pop()!}::${method.name}` }); + } if (method.language === 'dart') { const cut = method.qualifiedName.lastIndexOf('::'); const owner = cut > 0 ? method.qualifiedName.slice(0, cut).split('::').pop()! : ''; @@ -8599,37 +8625,200 @@ function rubyMethodsOfCallerKind(candidates: Node[], ref: UnresolvedRef, context 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 { +const RUBY_MIXINS = new WeakMap>(); + +/** What one declaration of a Ruby class or module adds to its ancestors, each list in the order Ruby looks there. */ +interface RubyMixins { + prepended: string[]; + included: string[]; + extended: string[]; + superclass: string | null; +} + +/** + * What a Ruby class or module declaration mixes in, read from its own body + * (a class nested in it has its own mixins) and found by constant lookup from + * there. The module mixed in last is asked first: rails' `save!` is + * `Transactions`', which `Base` includes after `Persistence`. + */ +function rubyMixinsOf(decl: Node, context: ResolutionContext): RubyMixins { + let memo = RUBY_MIXINS.get(context); + if (!memo) RUBY_MIXINS.set(context, (memo = new Map())); + const hit = memo.get(decl.id); + if (hit) return hit; + const q = decl.qualifiedName; + const found: RubyMixins = { prepended: [], included: [], extended: [], superclass: null }; + const lines = context.getFileLines?.(decl.filePath) ?? context.readFile(decl.filePath)?.split(/\r?\n/) ?? []; + const sup = /^\s*class\s+[\w:]+\s*<\s*(::)?([A-Z][\w:]*)/.exec(lines[decl.startLine - 1] ?? ''); + if (sup) found.superclass = rubyConstantQn(sup[2]!, sup[1] || !q.includes('::') ? '' : q.slice(0, q.lastIndexOf('::')), context); + const nested = context.getNodesInFile(decl.filePath).filter((n) => (n.kind === 'class' || n.kind === 'module') && n.id !== decl.id && + n.startLine > decl.startLine && n.endLine < decl.endLine); + for (let i = decl.startLine; i < decl.endLine && i < lines.length; i++) { + // A module a call returns (`include RouteSet.new.tap { … }.url_helpers`, + // actionpack's tests) is no mixin of the constant the call starts at. + const mix = /^\s*(include|extend|prepend)\s+([A-Z:][\w:]*(?:\s*,\s*[A-Z:][\w:]*)*)(?![\w:]|\s*[.(])/.exec(lines[i]!); + if (!mix || nested.some((n) => n.startLine <= i + 1 && n.endLine >= i + 1)) continue; + const names = mix[2]!.split(/\s*,\s*/).map((name) => name.startsWith('::') ? rubyConstantQn(name.slice(2), '', context) : rubyConstantQn(name, q, context)); + const list = mix[1] === 'include' ? found.included : mix[1] === 'prepend' ? found.prepended : found.extended; + list.unshift(...names); + } + memo.set(decl.id, found); + return found; +} + +/** + * A Ruby class's qualified name, those of its superclasses, and of every + * module mixed into any of them, in the order Ruby looks a method up. For + * what an instance answers (`instance`), a module the class `extend`s is + * left out: its methods are the class's own. + */ +export function rubyAncestry(qn: string, context: ResolutionContext, instance = false): Set { let memo = RUBY_ANCESTRY.get(context); if (!memo) RUBY_ANCESTRY.set(context, (memo = new Map())); - const hit = memo.get(qn); + const key = instance ? `instance ${qn}` : qn; + const hit = memo.get(key); if (hit) return hit; const seen = new Set(); - const queue = [qn]; - while (queue.length > 0 && seen.size < 60) { - const q = queue.shift()!; - if (seen.has(q)) continue; + const order = new Set(); + const visit = (q: string): void => { + if (seen.has(q) || seen.size >= 250) return; seen.add(q); - for (const decl of context.getNodesByQualifiedName(q)) { - if (decl.language !== 'ruby' || (decl.kind !== 'class' && decl.kind !== 'module')) continue; - const lines = context.getFileLines?.(decl.filePath) ?? context.readFile(decl.filePath)?.split(/\r?\n/) ?? []; - const outer = q.includes('::') ? q.slice(0, q.lastIndexOf('::')) : ''; - const sup = /^\s*class\s+[\w:]+\s*<\s*(::)?([A-Z][\w:]*)/.exec(lines[decl.startLine - 1] ?? ''); - if (sup) queue.push(rubyConstantQn(sup[2]!, sup[1] ? '' : outer, context)); - for (const line of lines.slice(decl.startLine, decl.endLine)) { - // A module a call returns (`include RouteSet.new.tap { … }.url_helpers`, - // actionpack's tests) is no mixin of the constant the call starts at. - const mix = /^\s*(?:include|extend|prepend)\s+([A-Z:][\w:]*(?:\s*,\s*[A-Z:][\w:]*)*)(?![\w:]|\s*[.(])/.exec(line); - if (!mix) continue; - for (const name of mix[1]!.split(/\s*,\s*/)) { - queue.push(name.startsWith('::') ? rubyConstantQn(name.slice(2), '', context) : rubyConstantQn(name, q, context)); + const mixins = context.getNodesByQualifiedName(q) + .filter((decl) => decl.language === 'ruby' && (decl.kind === 'class' || decl.kind === 'module')) + .map((decl) => rubyMixinsOf(decl, context)); + for (const m of mixins) m.prepended.forEach(visit); + order.add(q); + for (const m of mixins) { + m.included.forEach(visit); + if (!instance) m.extended.forEach(visit); + } + for (const m of mixins) if (m.superclass) visit(m.superclass); + }; + visit(qn); + memo.set(key, order); + return order; +} + +/** + * The instance method `name` a value of the Ruby class written `typeName` + * where `ref` is answers from its ancestors: the class Ruby's constant lookup + * finds there, then its superclasses and the modules they include. Walked by + * bare names, rails' `company.has_attribute?` took the `has_attribute?` of a + * `ClassMethods` module, one of dozens of that name. + */ +function rubyInstanceMethodOf(typeName: string, name: string, ref: UnresolvedRef, context: ResolutionContext): Node | null { + const looked = rubyConstantQn(typeName, rubyScopeAt(ref, context), context); + // A type read without its namespace (`Arel::Nodes::BindParam.new` is read as + // `BindParam`) is any class of that name. + const classes = context.getNodesByQualifiedName(looked).some(isRubyNamespace) ? [looked] + : [...new Set(context.getNodesByName(typeName.split('::').pop()!).filter(isRubyNamespace).map((n) => n.qualifiedName))]; + for (const qn of classes) { + for (const owner of rubyAncestry(qn, context, true)) { + const methods = rubyMethodsOf(owner, name, context); + if (methods.length > 0) return rubyMethodsOfKindFirst(methods, 'instance')[0]!; + } + } + return null; +} + +const RUBY_METHOD_OWNERS = new WeakMap>>(); + +/** The Ruby methods `name` of the class or module `owner`. A walk up a class's ancestors asks this of each one, for each method name. */ +function rubyMethodsOf(owner: string, name: string, context: ResolutionContext): Node[] { + let memo = RUBY_METHOD_OWNERS.get(context); + if (!memo) RUBY_METHOD_OWNERS.set(context, (memo = new Map())); + let owners = memo.get(name); + if (!owners) { + memo.set(name, (owners = new Map())); + for (const n of context.getNodesByName(name)) { + if (n.kind !== 'method' || n.language !== 'ruby') continue; + const at = n.qualifiedName.lastIndexOf('::'); + if (at < 0) continue; + const of = n.qualifiedName.slice(0, at); + const methods = owners.get(of); + if (methods) methods.push(n); + else owners.set(of, [n]); + } + } + return owners.get(owner) ?? []; +} + +function isRubyNamespace(n: Node): boolean { + return n.language === 'ruby' && (n.kind === 'class' || n.kind === 'module'); +} + +const RUBY_FILE_SCOPES = new WeakMap>(); + +/** The qualified name of the innermost class or module around `ref`, or '' at the top of its file. */ +function rubyScopeAt(ref: UnresolvedRef, context: ResolutionContext): string { + let memo = RUBY_FILE_SCOPES.get(context); + if (!memo) RUBY_FILE_SCOPES.set(context, (memo = new Map())); + let scopes = memo.get(ref.filePath); + if (!scopes) { + scopes = context.getNodesInFile(ref.filePath).filter((n) => n.kind === 'class' || n.kind === 'module') + .sort((a, b) => (a.endLine - a.startLine) - (b.endLine - b.startLine)); + memo.set(ref.filePath, scopes); + } + return scopes.find((n) => n.startLine <= ref.line && n.endLine >= ref.line)?.qualifiedName ?? ''; +} + +/** + * The class method `name` a Ruby class answers past its own: up its + * superclasses, a `def self.` of theirs, an instance method of a module one of + * them `extend`s, or of the `ClassMethods` a module they include brings along + * (ActiveSupport::Concern). Never an instance method of a module they + * include, nor a `def self.` of one, which stays that module's: + * rails' `Topic.create` is no `Reflection.create`. + */ +function rubyClassMethodOf(qn: string, name: string, context: ResolutionContext): Node | null { + const named = (owner: string, isStatic: boolean): Node | undefined => + rubyMethodsOf(owner, name, context).find((n) => (n.isStatic === true) === isStatic); + const seen = new Set(); + for (let cls: string | null = qn; cls !== null && !seen.has(cls);) { + seen.add(cls); + const decls: Node[] = context.getNodesByQualifiedName(cls).filter(isRubyNamespace); + if (cls !== qn) { + const own = named(cls, true); + if (own) return own; + } + for (const decl of decls) { + const mixins = rubyMixinsOf(decl, context); + for (const module of mixins.extended) { + for (const owner of rubyAncestry(module, context, true)) { + const method = named(owner, false); + if (method) return method; + } + } + for (const module of mixins.included) { + for (const owner of rubyAncestry(module, context, true)) { + const method = named(`${owner}::ClassMethods`, false); + if (method) return method; } } } + cls = decls.map((decl) => rubyMixinsOf(decl, context).superclass).find((up) => up !== null) ?? null; } - memo.set(qn, seen); - return seen; + return null; +} + +/** + * The method `name` the first of a Ruby class's ancestors declares, past the + * class itself: read through its real superclass and mixins, each found by + * Ruby's constant lookup. A base's bare name finds a namesake's method: + * `ActiveRecord::Base` is every `Base` in rails. + */ +function rubyAncestorMethod(qn: string, name: string, context: ResolutionContext, kind?: RubyMethodKind): Node | null { + if (kind === 'class') return rubyClassMethodOf(qn, name, context); + for (const owner of rubyAncestry(qn, context)) { + if (owner === qn) continue; + const methods = rubyMethodsOf(owner, name, context); + // A module's `def self.` stays the module's: rails' `topic.create` is no + // `Reflection.create`. + const reached = methods.find((n) => n.isStatic !== true) ?? + (context.getNodesByQualifiedName(owner).some((d) => d.kind === 'class') ? methods[0] : undefined); + if (reached) return reached; + } + return null; } /** The class or module a constant written inside `scope` names: the nearest enclosing namespace that has it, else the name itself. */ @@ -15336,6 +15525,11 @@ export function resolveMethodOnType( matches = matches.filter((m) => isKotlinExtensionInScope(m, ref, context)); } if (matches.length === 0) { + // A Ruby value's supertypes are its class's real ones, or none the project has. + if (ref.language === 'ruby') { + const inherited = depth === 0 ? rubyInstanceMethodOf(typeName, methodName, ref, context) : null; + return inherited && { original: ref, targetNodeId: inherited.id, confidence, resolvedBy }; + } // Conformance fallback: the method may be defined on a supertype `typeName` // extends, or on a protocol / trait it conforms to (e.g. a Swift protocol- // extension method, a C# default-interface or extension method, a Kotlin @@ -21483,6 +21677,9 @@ export function clearNameMatcherMemos(context: ResolutionContext): void { RUBY_ANCESTRY.delete(context); PHP_TYPE_METHODS.delete(context); PHP_ANCESTRIES.delete(context); + RUBY_MIXINS.delete(context); + RUBY_METHOD_OWNERS.delete(context); + RUBY_FILE_SCOPES.delete(context); CFML_CHAINS.delete(context); NO_RECEIVER_LINES.delete(context); OBJC_SUPERS.delete(context); @@ -23979,9 +24176,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. - if (ref.language === 'php') return null; - const inherited = inheritedClassMethod(classCandidates.filter((c) => c.language === ref.language), methodName!, context, - ref.language === 'ruby' ? 'class' : undefined); + if (ref.language === 'ruby' || ref.language === 'php') return null; + 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; }); @@ -24064,6 +24260,11 @@ export function matchMethodCall( // A class the PHP file can name comes first. const phpNamed = ref.language === 'php' ? fuzzyClassCandidates.filter((c) => phpFileNamesClass(c, ref.filePath, context)) : []; if (ref.language === 'php') fuzzyClassCandidates = [...phpNamed, ...fuzzyClassCandidates.filter((c) => !phpNamed.includes(c))]; + // In Ruby, the class constant lookup finds from the call comes first. + if (ref.language === 'ruby' && fuzzyClassCandidates.length > 1) { + const looked = rubyConstantQn(capitalizedReceiver, rubyScopeAt(ref, context), context); + fuzzyClassCandidates = [...fuzzyClassCandidates].sort((a, b) => Number(b.qualifiedName === looked) - Number(a.qualifiedName === looked)); + } const productionCaller = !isTestPath(ref.filePath); for (const classNode of fuzzyClassCandidates) { // Skip cross-language class matches @@ -29841,6 +30042,7 @@ function findOwnMethod(nodesInFile: Node[], classNode: Node, methodName: string, function classStrategyMethod(classNode: Node, methodName: string, context: ResolutionContext, kind?: RubyMethodKind): Node | undefined { const own = findOwnMethod(context.getNodesInFile(classNode.filePath), classNode, methodName, kind); if (own) return own; + if (classNode.language === 'ruby') return rubyAncestorMethod(classNode.qualifiedName, methodName, context, kind) ?? undefined; if (classNode.language === 'php') return phpAncestorMethod(classNode, methodName, context) ?? undefined; return inheritedClassMethod([classNode], methodName, context, kind) ?? undefined; } diff --git a/src/resolution/ruby-constant-paths.ts b/src/resolution/ruby-constant-paths.ts index aaf3d8526..46324c945 100644 --- a/src/resolution/ruby-constant-paths.ts +++ b/src/resolution/ruby-constant-paths.ts @@ -24,6 +24,11 @@ * So does a call on an instance variable an instance method makes * (`@writer.flush`), when the class's `initialize` gives the variable an * instance of one class, `@writer = CacheWriter.new(…)`, and nothing else. + * + * And a call on a method of the class around it, written as a bare name + * (`cookies.signed`), when that method, the class's own or one its ancestry + * declares, takes no arguments and hands back what one class makes: + * `@cookie_jar ||= Cookies::CookieJar.build(…)`. */ import type { Node } from '../types'; import type { ResolutionContext, ResolvedRef, UnresolvedRef } from './types'; @@ -42,6 +47,8 @@ const CONSTANT_PATH_CALL = /^(::)?([A-Z]\w*(?:::[A-Z]\w*)*)\.([A-Za-z_]\w*[?!=]? const CONSTRUCTED_CALL = /^new (::)?([A-Z]\w*(?:::[A-Z]\w*)*)\(\)\.([A-Za-z_]\w*[?!=]?)$/; /** `@writer.flush`: a call on an instance variable. Group 1 is the variable's name, group 2 the method. */ const IVAR_CALL = /^@(\w+)\.([A-Za-z_]\w*[?!=]?)$/; +/** `cookies.signed`: a call on a bare name. Group 1 is the name, group 2 the method. */ +const BARE_NAME_CALL = /^([a-z_]\w*)\.([A-Za-z_]\w*[?!=]?)$/; /** What a Ruby constant is in the index: a class, a module, or a value (`RATES = {…}` is a `variable`). */ const NAMESPACE_KINDS: ReadonlySet = new Set(['class', 'module']); @@ -86,6 +93,15 @@ export function matchRubyConstantPathRef(ref: UnresolvedRef, context: Resolution const method = cls ? methodOf(cls.qualifiedName, ivar[2]!, ref, context, 'instance') : null; return method ? { original: ref, targetNodeId: method.node.id, confidence: method.own ? 0.85 : 0.8, resolvedBy: 'instance-method' } : null; } + // A call on what a method of the class around it hands back. A name the + // calling method binds itself, or a method that says nothing of its value, + // is left to the other strategies. + const bare = BARE_NAME_CALL.exec(ref.referenceName); + if (bare) { + const cls = accessorClass(bare[1]!, ref, context); + const method = cls ? methodOf(cls.qualifiedName, bare[2]!, ref, context, 'instance') : null; + return method ? { original: ref, targetNodeId: method.node.id, confidence: method.own ? 0.85 : 0.8, resolvedBy: 'instance-method' } : undefined; + } const m = CONSTANT_PATH_CALL.exec(ref.referenceName); if (!m || !isNamespaced(m)) return undefined; const owner = constantsNamed(m[2]!, m[1] !== undefined, ref, context)[0]; @@ -187,6 +203,78 @@ function ivarClassFromInitialize(name: string, owner: string, context: Resolutio return cls?.kind === 'class' ? cls : null; } +const ACCESSOR_CLASSES = new WeakMap>(); + +/** Drops what was read from the files of a context whose caches are cleared. */ +export function clearRubyConstantPathMemos(context: ResolutionContext): void { + IVAR_CLASSES.delete(context); + ACCESSOR_CLASSES.delete(context); +} + +/** + * The class of what the method `name` of the class around `ref` hands back, a + * call written as a bare name in an instance method: undefined unless such a + * method says so (accessorClassOf) and the calling method doesn't bind the + * name itself. + */ +function accessorClass(name: string, ref: UnresolvedRef, context: ResolutionContext): Node | undefined { + const caller = context.getNodeById?.(ref.fromNodeId); + if (!caller || caller.kind !== 'method' || caller.language !== 'ruby' || caller.isStatic === true) return undefined; + const owner = innermostNamespace(ref, context); + if (!owner || owner.kind !== 'class') return undefined; + let memo = ACCESSOR_CLASSES.get(context); + if (!memo) ACCESSOR_CLASSES.set(context, (memo = new Map())); + const key = `${owner.qualifiedName}\0${name}`; + let cls = memo.get(key); + if (cls === undefined) memo.set(key, (cls = accessorClassOf(name, owner.qualifiedName, ref, context))); + if (!cls) return undefined; + const lines = context.getFileLines?.(caller.filePath) ?? context.readFile(caller.filePath)?.split(/\r?\n/) ?? []; + return bindsName(lines.slice(caller.startLine - 1, caller.endLine).join('\n'), name) ? undefined : cls; +} + +/** Whether a method's text binds `name` itself: a parameter, a block's, an assignment, a `rescue => name`. */ +function bindsName(text: string, name: string): boolean { + return new RegExp(String.raw`^\s*def\s[^\n]*\b${name}\b|\|[^|\n]*\b${name}\b[^|\n]*\||(?])|(?])|=>\s*${name}\b|\bfor\s+${name}\b`, 'm').test(text); +} + +/** + * The class what the method `name` of `owner` hands back: its own or the first + * its ancestry declares, taking no arguments, whose last statement is + * `Const.new(…)` or `Const.factory(…)`, memoized (`@x ||= …`) or not, a factory + * being a `def self.` of that class that ends in what `new` made. Null for + * anything else. + */ +function accessorClassOf(name: string, owner: string, ref: UnresolvedRef, context: ResolutionContext): Node | null { + const accessor = methodOf(owner, name, ref, context, 'instance')?.node; + if (!accessor || accessor.isStatic === true) return null; + const last = lastStatement(accessor, context); + if (!last || !new RegExp(String.raw`^\s*def\s+${name}\s*(?:\(\s*\))?\s*(?:#.*)?$`).test(last.head)) return null; + const value = last.text.replace(/^@\w+\s*\|\|=\s*/, ''); + const at: UnresolvedRef = { + fromNodeId: '', referenceName: name, referenceKind: 'references', + line: accessor.startLine, column: 0, filePath: accessor.filePath, language: 'ruby', + }; + const made = constructedValue(value, 0); + if (made) return constantsNamed(made.path, made.absolute, at, context).find((n) => n.kind === 'class') ?? null; + const factory = /^(::)?([A-Z]\w*(?:::[A-Z]\w*)*)\s*\.\s*([a-z_]\w*)\b/.exec(value); + if (!factory) return null; + const cls = constantsNamed(factory[2]!, factory[1] !== undefined, at, context).find((n) => n.kind === 'class'); + const built = cls && context.getNodesByQualifiedName(`${cls.qualifiedName}::${factory[3]}`).find((n) => n.kind === 'method' && n.isStatic === true); + const end = built ? lastStatement(built, context) : null; + if (!end) return null; + const local = /^\w+$/.test(end.text) ? new RegExp(String.raw`^\s*${end.text}\s*=\s*new\b`, 'm') : null; + return /^new\b/.test(end.text) || local?.test(end.body) ? cls! : null; +} + +/** The first line of a method, its body with comments blanked, and the body's last statement, one line long. */ +function lastStatement(method: Node, context: ResolutionContext): { head: string; body: string; text: string } | null { + const lines = context.getFileLines?.(method.filePath) ?? context.readFile(method.filePath)?.split(/\r?\n/); + if (!lines || method.endLine - method.startLine < 2) return null; + const body = stripCommentsForRegex(lines.slice(method.startLine, method.endLine - 1).join('\n'), 'ruby'); + const text = body.split('\n').map((l) => l.trim()).filter(Boolean).pop(); + return text ? { head: lines[method.startLine - 1] ?? '', body, text } : null; +} + /** * Each assignment to `@name` in `body` (an `initialize`, comments blanked): * the constant whose `.new` it assigns, and the assignment's line within @@ -265,7 +353,7 @@ function methodOf( kind: RubyMethodKind = 'class', ): { node: Node; own: boolean } | null { const production = !isTestPath(ref.filePath); - for (const qn of rubyAncestry(owner, context)) { + for (const qn of rubyAncestry(owner, context, kind === 'instance')) { const methods = context.getNodesByQualifiedName(`${qn}::${name}`).filter((n) => n.kind === 'method' && n.language === 'ruby' && !(production && isTestPath(n.filePath))); if (methods.length > 0) return { node: rubyMethodsOfKindFirst(ordered(methods, ref), kind)[0]!, own: qn === owner };