diff --git a/CHANGELOG.md b/CHANGELOG.md index 88de4b51..0fbd831f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -151,6 +151,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In C++, a call on a variable, parameter or member declared with template arguments nested inside its template arguments, like `autovector>` or `Striped>`, now links to the method of the class it is declared as. Before, the nested arguments hid that class: in RocksDB, `files_marked_for_compaction_.clear()` on such an `autovector` linked to an unrelated `CompactionInputFiles::clear` that only shares words with the variable's name, and `mutex_.Get(key)` on a `Striped` linked to nothing. Re-index C++ projects after upgrading. - In C++, a base class like `Message` in `class DynamicMessage final : public Message` now links to the class C++ itself would pick: the one declared in the namespaces around the class, or brought in by the file's `using` declarations and namespace aliases, and the class a `typedef` or `using` alias names. Before, a base class was matched by its name alone, so it could land on a class of the same name in another namespace or another language: in protocolbuffers/protobuf, `DynamicMessage` derived from the JSON parser's internal `Message` class, and in google/leveldb and RocksDB, iterators derived from an unrelated nested `Iterator` class such as the skip list's. The wrong base showed up in `codegraph_explore`'s type hierarchy and in impact, and tied the base's methods to the wrong overrides. A template parameter used as a base no longer links to a class that shares its name, and a base written from the global scope, like `public ::testing::Test`, now links to its class. Re-index C++ projects after upgrading. - In C++ and Rust, a call written with a qualifier, like `Status::OK()`, now links to a method of the class it names, never to one of a class whose name only ends the same way. Before, facebook/rocksdb's calls to `Status::OK()`, `Status::InvalidArgument(…)` and `Status::Corruption(…)` linked to `IOStatus`'s methods of those names, a subclass calling its base, like `return FileSystemWrapper::NewWritableFile(…)` or google/leveldb's `return DB::Put(…)`, linked to another subclass or to the calling method itself, and BurntSushi/ripgrep's `Path::new(…)` on the standard library's `Path` linked to its own `PrinterPath::new`. When classes in several languages share the name, the one in the call's own language is taken, so protocolbuffers/protobuf's C++ `DescriptorPool::generated_pool()` calls, which met its Ruby namesake first and linked to nothing, now link to the C++ method. A C++ call through a template parameter, like `CacheShard::ComputeHash(key)` in rocksdb's `template class ShardedCache`, no longer links to a class that merely shares words with the parameter's name, unless that class is the parameter's default, like `TcParser` in `template `. A template's value parameter, like `const PrefetchOpts& kOpts`, is no longer mistaken for a type parameter, so references to `PrefetchOpts` inside it now link. Re-index C++ and Rust projects after upgrading. +- 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 C++, a call through a class template's specialization written with its arguments, like fmtlib/fmt's `formatter::format(42, ctx)`, no longer links to a member defined for another specialization, like a test's `formatter::format`, and when the template declares no such member, as for `formatter::parse(ctx)`, the call is no longer matched to another class's method of that name, like `native_formatter::parse`. A call to the specialization a member is defined for, and to a template's own members, links as before. Re-index C++ projects after upgrading. - In C++, a function is now named by its declarator, never by something written in the clauses after its parameters. nlohmann/json's constructor `basic_json(CompatibleType&& val) noexcept(noexcept(JSONSerializer::to_json(…)))` was missing from the index, replaced by a method `JSONSerializer::to_json` that doesn't exist and that other `JSONSerializer::to_json(…)` calls linked to; a method declared with `throw(std::bad_alloc)` was indexed as `std::bad_alloc`, and a function with a trailing `requires std::integral` as `std::integral`. Re-index C++ projects after upgrading. - In C and C++, a header or source file named like a test, such as protocolbuffers/protobuf's `conformance_test.h` and `test_runner.h`, now counts as part of the code that `#include`s it, directly or through other headers. Before, calls from that code into it were dropped, so protobuf's conformance suites' `suite_.ReportFailure(…)` and `RunValidInputTest(…)` calls, and its unit tests' calls to the shared `TestUtil` helpers, linked to nothing. A test file that only tests include is still kept out of reach of the rest of your code. Re-index C and C++ projects after upgrading. diff --git a/__tests__/kernel-ruby-parity.test.ts b/__tests__/kernel-ruby-parity.test.ts index 70390e99..9dcf00c5 100644 --- a/__tests__/kernel-ruby-parity.test.ts +++ b/__tests__/kernel-ruby-parity.test.ts @@ -107,6 +107,47 @@ describe.skipIf(!kernelBuilt)('kernel Ruby extraction parity', () => { assertParity('fixtures/torture.rb (crlf)', crlf, 30); }); + // A receiver written with its namespace (`Billing::ChargeCard.call`, `::Foo.call`) + // is a constant read, as a bare `ChargeCard.call`'s is (#2503): both backends + // emit the same `references` ref for it, under the receiver as written, at + // the receiver. A path through a value (`obj::Thing`, `self.class::LIMIT`) + // names no constant the index can know, and `.new` stays an instantiation. + it('namespaced constant receivers', () => { + const src = [ + 'class Callers', + ' def a', + ' Billing::ChargeCard.call(amount: 1)', + ' ::Billing::ChargeCard.call', + ' ::Foo.call', + ' Users::Create&.call', + ' A::B::C.run!', + ' Config::DEFAULTS.fetch(:x)', + ' Foo::Bar::baz', + ' obj::Thing.go', + ' self.class::LIMIT.times { }', + ' Billing::ChargeCard.new(amount: 3)', + ' end', + 'end', + '', + ].join('\n'); + assertParity('src/callers.rb', src); + process.env.CODEGRAPH_KERNEL = '0'; + const refs = extractFromSource('src/callers.rb', src, 'ruby').unresolvedReferences + .filter((r) => r.referenceKind === 'references' || r.referenceKind === 'instantiates') + .map((r) => `${r.referenceKind} ${r.referenceName} ${r.line}:${r.column}`); + delete process.env.CODEGRAPH_KERNEL; + expect(refs.sort()).toEqual([ + 'instantiates ChargeCard 12:4', + 'references ::Billing::ChargeCard 4:4', + 'references ::Foo 5:4', + 'references A::B::C 7:4', + 'references Billing::ChargeCard 3:4', + 'references Config::DEFAULTS 8:4', + 'references Foo::Bar 9:4', + 'references Users::Create 6:4', + ]); + }); + 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-namespaced-receiver.test.ts b/__tests__/ruby-namespaced-receiver.test.ts new file mode 100644 index 00000000..e62c0ca0 --- /dev/null +++ b/__tests__/ruby-namespaced-receiver.test.ts @@ -0,0 +1,312 @@ +/** + * A Ruby call on a constant written with its namespace — + * `Billing::ChargeCard.call(…)`, `::Foo.call` — depends on the class it names + * and calls that class's method, as `ChargeCard.call(…)` already did (#2503). + * The constant is the one Ruby's lookup finds from where it is written: the + * nearest namespace around the call that declares it, then the top level; a + * name written from the top level (`::Foo`) is looked up there only. A + * constant the project doesn't declare is a library's (`ActiveRecord::Base`) + * and links nothing, never a project class or method sharing its last name. + * + * 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 issue's three files. + '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 +`, + 'app/services/checkout/complete.rb': `module Checkout + class Complete + def call + Billing::ChargeCard.call(amount: 1) + end + end +end +`, + 'app/services/billing/refund.rb': `module Billing + class Refund + def call + ChargeCard.call(amount: 2) + end + end +end +`, + // One class name in two namespaces: the caller names which one it means. + 'app/billing/create.rb': `module Billing + class Create + def self.call = 1 + end +end +`, + 'app/users/create.rb': `module Users + class Create + def self.call = 2 + end +end +`, + 'app/foo.rb': `class Foo + def self.call = 3 +end +`, + 'app/svc/foo.rb': `module Svc + class Foo + def self.call = 4 + end +end +`, + 'app/svc/runner.rb': `module Svc + class Runner + def billing = Billing::Create.call + def users = Users::Create.call + def users_safe = Users::Create&.call + def rooted = ::Billing::Create.call + def rooted_top = ::Foo.call + end +end +`, + // The same relative name under two API versions: the one around the call. + 'app/api/v1/users/create.rb': `module Api + module V1 + module Users + class Create + def self.call = 5 + end + end + end +end +`, + 'app/api/v2/users/create.rb': `module Api + module V2 + module Users + class Create + def self.call = 6 + end + end + end +end +`, + 'app/api/v2/users_controller.rb': `module Api + module V2 + class UsersController + def create + Users::Create.call + end + end + end +end +`, + // A library constant whose last name a project class shares. + 'app/base.rb': `class Base + def self.transaction = 7 +end +`, + 'app/models/order.rb': `class Order + def save! + ActiveRecord::Base.transaction { } + Stripe::Charge.create(amount: 1) + end +end +`, + // A test's stand-in for a library class, which production code never reaches. + 'spec/support/stripe.rb': `module Stripe + class Charge + def self.create(**) = 9 + end +end +`, + 'spec/models/order_spec.rb': `class OrderSpec + def test_charge + Stripe::Charge.create(amount: 1) + end +end +`, + // A module's method, a class method a superclass declares, a class declared + // with a compact name, a method the class doesn't declare, a constant value. + 'app/billing/helpers.rb': `module Billing + module Helpers + def self.fmt(x) = x + end + class Document + def self.build = new + end + class Invoice < Document + end + RATES = { usd: 1 } +end +`, + 'app/billing/statement.rb': `class Billing::Statement + def self.render = 8 +end +`, + 'app/billing/query.rb': `class Query + def where(x) = x +end +`, + 'app/billing/report.rb': `class Report + def run + Billing::Helpers.fmt(1) + Billing::Invoice.build + Billing::Statement.render + Billing::ChargeCard.where(amount: 1) + Billing::RATES.fetch(:usd) + 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; +}); + +function write(files: Record): void { + 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); + } +} + +async function index(backend: string, files: Record = FILES): 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-nsrecv-')); + write(files); + cg = await CodeGraph.init(root, { index: true }); + return cg; +} + +/** ` :` of every `calls`/`references` edge written on `line` of `file`. */ +function edgesAt(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.kind === 'references') && e.line === line) + .map((e) => { + const t = graph.getNode(e.target)!; + return `${e.kind} ${t.qualifiedName}:${t.startLine}`; + }) + .sort(); +} + +/** `:` of every `references` edge into the class or module `qualifiedName`. */ +function referencedFrom(graph: CodeGraph, qualifiedName: string): string[] { + const target = graph.getNodesByQualifiedName(qualifiedName).find((n) => n.kind === 'class' || n.kind === 'module')!; + return graph + .getIncomingEdgesTo([target.id]) + .filter((e) => e.kind === 'references') + .map((e) => `${graph.getNode(e.source)!.filePath}:${e.line}`) + .sort(); +} + +const backends = [ + ['kernel', getKernel() === null], + ['wasm', false], +] as const; + +describe.each(backends)('Ruby call on a namespaced constant (%s)', (backend, skip) => { + it.skipIf(skip)('links the caller to the class, as a bare constant receiver does', async () => { + const graph = await index(backend); + expect(referencedFrom(graph, 'Billing::ChargeCard')).toEqual([ + 'app/billing/report.rb:6', + 'app/services/billing/refund.rb:4', + 'app/services/checkout/complete.rb:4', + ]); + }); + + it.skipIf(skip)("calls the class's method", async () => { + const graph = await index(backend); + expect(edgesAt(graph, 'app/services/checkout/complete.rb', 4)).toEqual([ + 'calls Billing::ChargeCard::call:3', + 'references Billing::ChargeCard:2', + ]); + }); + + it.skipIf(skip)('reaches the class of the namespace the caller names', async () => { + const graph = await index(backend); + const runner = 'app/svc/runner.rb'; + expect(edgesAt(graph, runner, 3)).toEqual(['calls Billing::Create::call:3', 'references Billing::Create:2']); + expect(edgesAt(graph, runner, 4)).toEqual(['calls Users::Create::call:3', 'references Users::Create:2']); + expect(edgesAt(graph, runner, 5)).toEqual(['calls Users::Create::call:3', 'references Users::Create:2']); + }); + + it.skipIf(skip)('looks a ::-rooted name up at the top level only', async () => { + const graph = await index(backend); + const runner = 'app/svc/runner.rb'; + expect(edgesAt(graph, runner, 6)).toEqual(['calls Billing::Create::call:3', 'references Billing::Create:2']); + // `::Foo` inside `module Svc` is the top-level Foo, not Svc::Foo. + expect(edgesAt(graph, runner, 7)).toEqual(['calls Foo::call:2', 'references Foo:1']); + }); + + it.skipIf(skip)('looks a relative name up in the namespaces around the call first', async () => { + const graph = await index(backend); + expect(edgesAt(graph, 'app/api/v2/users_controller.rb', 5)).toEqual([ + 'calls Api::V2::Users::Create::call:5', + 'references Api::V2::Users::Create:4', + ]); + }); + + it.skipIf(skip)('links nothing for a constant the project does not declare', async () => { + const graph = await index(backend); + expect(edgesAt(graph, 'app/models/order.rb', 3)).toEqual([]); + expect(referencedFrom(graph, 'Base')).toEqual([]); + }); + + it.skipIf(skip)('never links production code to a class only a test declares', async () => { + const graph = await index(backend); + expect(edgesAt(graph, 'app/models/order.rb', 4)).toEqual([]); + expect(edgesAt(graph, 'spec/models/order_spec.rb', 3)).toEqual([ + 'calls Stripe::Charge::create:3', + 'references Stripe::Charge:2', + ]); + }); + + it.skipIf(skip)('calls module methods, inherited class methods and compact-named classes, and guesses no method', async () => { + const graph = await index(backend); + const report = 'app/billing/report.rb'; + expect(edgesAt(graph, report, 3)).toEqual(['calls Billing::Helpers::fmt:3', 'references Billing::Helpers:2']); + expect(edgesAt(graph, report, 4)).toEqual(['calls Billing::Document::build:6', 'references Billing::Invoice:8']); + expect(edgesAt(graph, report, 5)).toEqual(['calls Billing::Statement::render:2', 'references Billing::Statement:1']); + // ChargeCard declares no `where`; Query's is not it. + expect(edgesAt(graph, report, 6)).toEqual(['references Billing::ChargeCard:2']); + // A constant holding a value: the call is the value's. + expect(edgesAt(graph, report, 7)).toEqual(['references Billing::RATES:10']); + }); +}); + +describe('Ruby call on a namespaced constant, after a sync', () => { + it('links the calls once a sync adds the class they name', async () => { + const { 'app/services/billing/charge_card.rb': chargeCard, ...rest } = FILES; + const graph = await index('default', rest); + expect(edgesAt(graph, 'app/services/checkout/complete.rb', 4)).toEqual([]); + + write({ 'app/services/billing/charge_card.rb': chargeCard! }); + expect((await graph.sync()).filesAdded).toBe(1); + // complete.rb did not change: only the retry of its parked refs links it. + expect(edgesAt(graph, 'app/services/checkout/complete.rb', 4)).toEqual([ + 'calls Billing::ChargeCard::call:3', + 'references Billing::ChargeCard:2', + ]); + }, 60_000); +}); diff --git a/codegraph-kernel/src/ruby.rs b/codegraph-kernel/src/ruby.rs index 3e4e33cb..101a3271 100644 --- a/codegraph-kernel/src/ruby.rs +++ b/codegraph-kernel/src/ruby.rs @@ -820,10 +820,12 @@ impl<'t> Walker<'t> { }; self.push_ref(caller, &callee, calls_kind, line, col); - // Capitalized constant receiver (`Klass.static_call`, `RETRY_MAX.times`) - // → an ADDITIONAL references ref at the RECEIVER's position. - // (scope_resolution receivers get none — type ≠ constant.) - if !skip && receiver.kind() == "constant" { + // Capitalized constant receiver (`Klass.static_call`, `RETRY_MAX.times`), + // or one written with its namespace (`Billing::ChargeCard.call`, + // `::Foo.call` — #2503) → an ADDITIONAL references ref at the + // RECEIVER's position, under the receiver as written. A path through + // a value (`obj::Thing`, `self.class::LIMIT`) names no constant. + if !skip && is_constant_path(receiver) { self.push_ref_at( caller, &receiver_name.to_string(), @@ -1121,6 +1123,19 @@ impl<'t> Walker<'t> { } } +/// A constant, or a `scope_resolution` path of constants (`A::B::C`, a +/// top-level `::A` / `::A::B`): what Ruby reads as a constant (isRubyConstantPath). +fn is_constant_path(node: Node) -> bool { + match node.kind() { + "constant" => true, + "scope_resolution" => { + node.child_by_field_name("name").map_or(false, |n| n.kind() == "constant") + && node.child_by_field_name("scope").map_or(true, is_constant_path) + } + _ => 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 3a02bac0..120e40ec 100644 --- a/docs/design/ruby-kernel-port-checklist.md +++ b/docs/design/ruby-kernel-port-checklist.md @@ -429,13 +429,17 @@ field_expression machinery, which ruby therefore NEVER runs. Steps: normalized to `()`**, unlike java/php chain encodings); literal receivers are NOT filtered (`"literal".upcase` → `"literal".upcase`, `5.times` → `5.times` — unresolvable noise, PRESERVE). -6. Receiver node TYPE exactly `constant` (and not skipped) → ADDITIONAL - `references` ref {name: receiverName, line/col: **the RECEIVER's** - position} — `Klass.static_call` emits calls `Klass.static_call` + - references `Klass`; fires for VALUE constants too (`RETRY_MAX.times {…}` - → calls `RETRY_MAX.times` + references `RETRY_MAX` — pinned). A - `scope_resolution` receiver (`Foo::Bar.baz`) gets NO references ref - (type ≠ constant). PRESERVE. +6. Receiver a CONSTANT PATH (and not skipped) → ADDITIONAL `references` + ref {name: receiverName, line/col: **the RECEIVER's** position} — + `Klass.static_call` emits calls `Klass.static_call` + references `Klass`; + fires for VALUE constants too (`RETRY_MAX.times {…}` → calls + `RETRY_MAX.times` + references `RETRY_MAX` — pinned). A constant path is + a `constant`, or a `scope_resolution` whose `name` is a `constant` and + whose `scope` is absent or itself a constant path (#2503): `Foo::Bar.baz` + → references `Foo::Bar`, `::Foo.baz` → references `::Foo` (the leading + `::` kept — the resolver reads it as "from the top level"). A path + through a value (`obj::Thing.go`, `self.class::LIMIT.times`) gets NONE. + Both engines: `is_constant_path` / `isRubyConstantPath`. 7. return — inner receiver calls are ALSO visited afterwards (the body walker recurses children at 5277 — extractCall does not consume its subtree), so `chained.first_call.second_call` emits BOTH diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 162714c9..caf11234 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -305,6 +305,18 @@ function swiftPropertyInfo( return { nameNode: firstSimpleIdentifier(pattern), isLet, isComputed }; } +/** + * A Ruby constant, or a `scope_resolution` path of constants (`A::B::C`, a + * top-level `::A` / `::A::B`): what Ruby reads as a constant. A path through + * a value (`obj::Thing`, `self.class::LIMIT`) is not one. + */ +function isRubyConstantPath(node: SyntaxNode): boolean { + if (node.type === 'constant') return true; + if (node.type !== 'scope_resolution') return false; + const scope = getChildByField(node, 'scope'); + return getChildByField(node, 'name')?.type === 'constant' && (!scope || isRubyConstantPath(scope)); +} + /** True when `node` is (transitively) inside a C function body — i.e. a local, * not a file/namespace-scope declaration. Walks the parent chain to the root. */ function hasFunctionAncestor(node: SyntaxNode): boolean { @@ -5444,7 +5456,10 @@ export class TreeSitterExtractor { // — is itself a dependency on that constant; emit a `references` ref so a // class used only via its class methods still records a dependent (the edge // the old receiver-only callee happened to provide, now made explicit). - if (!skip && receiverNode.type === 'constant') { + // So is one written with its namespace, `Billing::ChargeCard.call` or + // `::Foo.call` (#2503), under the receiver as written; a path through a + // value (`obj::Thing`, `self.class::LIMIT`) names no constant. + if (!skip && isRubyConstantPath(receiverNode)) { this.unresolvedReferences.push({ fromNodeId: callerId, referenceName: receiverName, diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 5e3e46ea..c568b3de 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -41,6 +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 { 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'; @@ -1574,6 +1575,15 @@ export class ReferenceResolver { const phpQualified = resolvePhpQualifiedClassRef(ref, this.context); if (phpQualified !== undefined) return this.gateLanguage(phpQualified, ref); + // A Ruby constant written with its namespace (`Billing::ChargeCard`, + // `::Foo`) and a call on one (`Billing::ChargeCard.call`) are what Ruby's + // constant lookup finds from where they are written, or nothing the + // project declares (see ./ruby-constant-paths). The pre-filter below + // drops a `::`-rooted name, and the strategies past it would take a + // namesake by its last name. + const rubyPath = matchRubyConstantPathRef(ref, this.context); + if (rubyPath !== undefined) return this.gateLanguage(rubyPath, ref); + // Fast pre-filter: skip if no symbol with this name exists anywhere // AND the name doesn't match a local import. The import escape is // necessary because re-export rename chains (`import { login } diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index aa3c74d8..e4098cd8 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -7805,7 +7805,7 @@ function isRubyMethodInScope(method: Node, ref: UnresolvedRef, context: Resoluti } /** A Ruby class's qualified name, those of its superclasses, and of every module mixed into any of them. */ -function rubyAncestry(qn: string, context: ResolutionContext): Set { +export function rubyAncestry(qn: string, context: ResolutionContext): Set { let memo = RUBY_ANCESTRY.get(context); if (!memo) RUBY_ANCESTRY.set(context, (memo = new Map())); const hit = memo.get(qn); diff --git a/src/resolution/ruby-constant-paths.ts b/src/resolution/ruby-constant-paths.ts new file mode 100644 index 00000000..b77e34fa --- /dev/null +++ b/src/resolution/ruby-constant-paths.ts @@ -0,0 +1,118 @@ +/** + * A Ruby constant written with its namespace — `Billing::ChargeCard`, or from + * the top level, `::Foo` and `::Billing::ChargeCard` — is the class, module + * or constant Ruby's lookup finds from where it is written (#2503): the + * nearest namespace around the reference that declares it, then the top + * level; one written from the top level is looked up there only. The + * extractor records such a call receiver as a `references` ref beside the + * call, as it does a bare one (`ChargeCard.call`), and the call itself as + * `Billing::ChargeCard.call`: a method of that class or module, its own or + * one its ancestry declares. + * + * Both resolve here alone. A path the project doesn't declare is a library's + * (`ActiveRecord::Base.transaction`), where the strategies past this one + * would take a project class or method sharing its last name; and a class + * the path names without the method (`Billing::ChargeCard.where`) has it from + * somewhere the index can't see, never from another class that declares one. + * Production code never names a class a test declares. A bare constant + * (`ChargeCard`) is left to the name strategies, as before. + */ +import type { Node } from '../types'; +import type { ResolutionContext, ResolvedRef, UnresolvedRef } from './types'; +import { isTestPath } from '../search/query-utils'; +import { rubyAncestry } 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*)*)$/; +/** `A::B.m`, `::A.m`: a call on one. Group 3 is the method. */ +const CONSTANT_PATH_CALL = /^(::)?([A-Z]\w*(?:::[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']); +const VALUE_KINDS: ReadonlySet = new Set(['variable', 'constant']); +const CONSTANT_KINDS: ReadonlySet = new Set([...NAMESPACE_KINDS, ...VALUE_KINDS]); + +/** + * What a Ruby `references` ref written as a constant path names, or the + * method a call on one reaches (see the file comment). Undefined for any + * other ref, which the other strategies resolve; null when the project + * declares no such constant, or the class or module it names has no method + * of the name. + */ +export function matchRubyConstantPathRef(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null | undefined { + if (ref.language !== 'ruby') return undefined; + if (ref.referenceKind === 'references') { + const m = CONSTANT_PATH.exec(ref.referenceName); + if (!m || !isNamespaced(m)) return undefined; + const target = constantsNamed(m[2]!, m[1] !== undefined, ref, context)[0]; + return target ? { original: ref, targetNodeId: target.id, confidence: 0.9, resolvedBy: 'qualified-name' } : null; + } + if (ref.referenceKind === 'calls') { + 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]; + const method = owner && NAMESPACE_KINDS.has(owner.kind) ? methodOf(owner.qualifiedName, m[3]!, ref, context) : null; + return method ? { original: ref, targetNodeId: method.node.id, confidence: method.own ? 0.85 : 0.8, resolvedBy: 'qualified-name' } : null; + } + return undefined; +} + +/** Whether a matched path is written with a `::`, which a bare `Foo` is not. */ +function isNamespaced(m: RegExpExecArray): boolean { + return m[1] !== undefined || m[2]!.includes('::'); +} + +/** + * The declarations of the constant `path` names where `ref` is written: those + * of the first of `::`, `::`, … `` the + * project declares, the scope being the class or module around the + * reference — only `` itself when it is written from the top level. + * A class or module comes before a value of the name, then the reference's + * own file, then production code. + */ +function constantsNamed(path: string, absolute: boolean, ref: UnresolvedRef, context: ResolutionContext): Node[] { + const production = !isTestPath(ref.filePath); + let prefix = absolute ? '' : lexicalScope(ref, context); + for (;;) { + const qn = prefix ? `${prefix}::${path}` : path; + const found = context.getNodesByQualifiedName(qn).filter((n) => + n.language === 'ruby' && CONSTANT_KINDS.has(n.kind) && !(production && isTestPath(n.filePath))); + if (found.length > 0) return ordered(found, ref); + if (!prefix) return []; + const cut = prefix.lastIndexOf('::'); + prefix = cut >= 0 ? prefix.slice(0, cut) : ''; + } +} + +/** The qualified name of the innermost class or module around `ref`, or '' at the top level of its file. */ +function lexicalScope(ref: UnresolvedRef, context: ResolutionContext): string { + let inner: Node | undefined; + for (const n of context.getNodesInFile(ref.filePath)) { + if (!NAMESPACE_KINDS.has(n.kind) || n.language !== 'ruby' || n.startLine > ref.line || n.endLine < ref.line) continue; + // Of the ranges around a point, the innermost starts last. + if (!inner || n.startLine > inner.startLine || (n.startLine === inner.startLine && n.startColumn > inner.startColumn)) inner = n; + } + return inner?.qualifiedName ?? ''; +} + +/** + * 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. + */ +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 }; + } + return null; +} + +/** Anything before a value, then the reference's own file, then production code; the index's order within each. */ +function ordered(nodes: Node[], ref: UnresolvedRef): Node[] { + const rank = (n: Node): number => + (VALUE_KINDS.has(n.kind) ? 4 : 0) + (n.filePath === ref.filePath ? 0 : 2) + (isTestPath(n.filePath) ? 1 : 0); + return nodes.length < 2 ? nodes : [...nodes].sort((a, b) => rank(a) - rank(b)); +}