Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>, 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)
Expand Down
45 changes: 45 additions & 0 deletions __tests__/kernel-ruby-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
212 changes: 212 additions & 0 deletions __tests__/ruby-singleton-methods.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
// 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<CodeGraph> {
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;
}

/** `<qualified name>:<line>` 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']);
});
});
33 changes: 30 additions & 3 deletions codegraph-kernel/src/ruby.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -97,6 +97,9 @@ struct Extra {
docstring: Option<String>,
signature: Option<String>,
visibility: Option<u8>,
/// The isStatic hook (languages/ruby.ts): set on every def, true for a
/// class method (is_class_method).
is_static: Option<bool>,
}

struct ValueScope<'t> {
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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)
Expand All @@ -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 });
Expand All @@ -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 };

Expand Down Expand Up @@ -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']).
Expand Down Expand Up @@ -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<Node> {
Expand Down
Loading