From 89ff9d7f8d8599b0d7a24227911aabdd834a0025 Mon Sep 17 00:00:00 2001 From: Alexandre Dubois Date: Tue, 10 Nov 2026 20:18:26 +0100 Subject: [PATCH] fix ruby lsp --- package.json | 2 +- skills/coderuler-new-rule/reference.md | 6 +- src/adapter/lsp/backend.ts | 111 ++-- src/adapter/lsp/coderuler-ruby-lsp-compat.rb | 90 ++++ src/adapter/lsp/ruby-call-graph.ts | 505 ++++++++++++++++++ src/adapter/lsp/ruby-calls.rb | 144 +++++ tests/fixtures/ruby-lsp/call-expected.json | 287 ++++++++++ tests/fixtures/ruby-lsp/call_graph.rb | 201 +++++++ .../project-dependency/lib/support_widget.rb | 13 +- tests/lsp-process.test.ts | 21 +- tests/ruby-lsp.test.ts | 231 +++++++- website/src/content/docs/writing-rules.md | 6 +- 12 files changed, 1557 insertions(+), 60 deletions(-) create mode 100644 src/adapter/lsp/coderuler-ruby-lsp-compat.rb create mode 100644 src/adapter/lsp/ruby-call-graph.ts create mode 100755 src/adapter/lsp/ruby-calls.rb create mode 100644 tests/fixtures/ruby-lsp/call-expected.json create mode 100644 tests/fixtures/ruby-lsp/call_graph.rb diff --git a/package.json b/package.json index 3237e31..35c6055 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "skills/coderuler-new-rule" ], "scripts": { - "build": "tsc -p tsconfig.build.json", + "build": "tsc -p tsconfig.build.json && node -e \"const fs = require('node:fs'); fs.mkdirSync('dist/adapter/lsp', { recursive: true }); for (const file of ['ruby-calls.rb', 'coderuler-ruby-lsp-compat.rb']) fs.copyFileSync('src/adapter/lsp/' + file, 'dist/adapter/lsp/' + file);\"", "dev": "npx tsx src/cli.ts", "start": "node dist/cli.js", "test": "npm run build && node --test --test-concurrency=4 tests/*.test.ts", diff --git a/skills/coderuler-new-rule/reference.md b/skills/coderuler-new-rule/reference.md index b0ec37e..9619eff 100644 --- a/skills/coderuler-new-rule/reference.md +++ b/skills/coderuler-new-rule/reference.md @@ -396,11 +396,11 @@ export default class Local extends Rule { Use `trace` for decision evidence visible during inspection; keep normal execution free of ad-hoc console output. AI/regex projection callbacks receive only `ProjectContext`, not the full check context. -`context.goLsp` provides Go-to-definition, declaration, type-definition, implementation, reference, hover, document-symbol, workspace-symbol, caller, and callee queries. `context.rubyLsp` provides Ruby definitions, references, hover, document symbols, and workspace symbols. `context.typescriptLsp` provides TypeScript definitions, type definitions, implementations, references, hover, document symbols, workspace symbols, callers, and callees; declaration lookup is not supported. LSP use is optional. Install Go and `gopls`, Shopify Ruby LSP (`ruby-lsp`), or native TypeScript 7's `tsc` on `PATH` only when a rule uses that language's helpers. CodeRuler does not install these servers. An unsupported operation rejects with a capability error; for example, gopls v0.24.0 does not support declaration lookup. +`context.goLsp` provides Go-to-definition, declaration, type-definition, implementation, reference, hover, document-symbol, workspace-symbol, caller, and callee queries. `context.rubyLsp` provides Ruby definitions, references, hover, document symbols, workspace symbols, callers, and callees. `context.typescriptLsp` provides TypeScript definitions, type definitions, implementations, references, hover, document symbols, workspace symbols, callers, and callees; declaration lookup is not supported. LSP use is optional. Install Go and `gopls`, Shopify Ruby LSP (`ruby-lsp`), or native TypeScript 7's `tsc` on `PATH` only when a rule uses that language's helpers. Ruby caller/callee queries also need a Ruby runtime and Prism available in the Ruby LSP bundle. CodeRuler does not install these servers. An unsupported operation rejects with a capability error; for example, gopls v0.24.0 does not support declaration lookup. -Pass file paths and positive, 1-based line and UTF-16 column coordinates. A column counts UTF-16 code units. Navigation and hover use column 1 when omitted. Caller and callee queries (Go and TypeScript) use the enclosing named function or method when the column is omitted. Their required `depth` is a nonnegative integer; depth 1 returns direct neighbors. Set `includeDependencies: true` to include available dependency or standard-library source outside the project; the default is `false`. Ruby dependency definitions are excluded by default and can be requested with `includeDependencies: true`. +Pass file paths and positive, 1-based line and UTF-16 column coordinates. A column counts UTF-16 code units. Navigation and hover use column 1 when omitted. Caller and callee queries (Go and TypeScript) use the enclosing named function or method when the column is omitted. Ruby caller/callee queries use the enclosing method for a line-only target; a column can target a declaration or call site. Their required `depth` is a nonnegative integer; depth 1 returns direct neighbors. Set `includeDependencies: true` to include available dependency or standard-library source outside the project; the default is `false`. Ruby dependency callers are scanned only when `includeDependencies` is `true`. -Caller and callee results are a static call graph. They can omit dynamic calls and do not load every possible package consumer. Use them as the server's known call relationships, not as a complete runtime graph. +Caller and callee results are static call graphs. Ruby results are best-effort: ambiguous calls retain every candidate definition, while dynamic or metaprogrammed calls may be missing. Ruby LSP may not resolve assignment setters such as `obj.value = x`; unresolved setter edges are omitted rather than reported as getter calls. An empty result does not prove a method is unused. Go and TypeScript results can also omit dynamic calls and do not load every possible package consumer; use results as known call relationships, not as a complete runtime graph. Call graph helpers from an async custom check: diff --git a/src/adapter/lsp/backend.ts b/src/adapter/lsp/backend.ts index b195e23..a1d914e 100644 --- a/src/adapter/lsp/backend.ts +++ b/src/adapter/lsp/backend.ts @@ -1,6 +1,6 @@ import { spawn } from 'node:child_process'; import { readFile } from 'node:fs/promises'; -import { basename, extname, isAbsolute, relative, resolve, sep } from 'node:path'; +import { basename, delimiter, dirname, extname, isAbsolute, relative, resolve, sep } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { parse, type SgNode, type SgRoot } from '@ast-grep/napi'; import { @@ -49,6 +49,7 @@ import type { LspCall, LspCallOptions, LspOptions, LspReferenceOptions, LspTarge import type { LspBackend, Project } from '../../app/ports.js'; import { resolveAstGrepLanguage } from '../scope/astgrep-languages.js'; import { textLineSpan, type LineSpan } from '../scope/lines.js'; +import { createRubyCallGraph } from './ruby-call-graph.js'; interface DeclarationResult { readonly source: string; @@ -260,9 +261,23 @@ export async function createLspBackend( const rootUri = pathToFileURL(root).href; const workspaceFolder: WorkspaceFolder = { uri: rootUri, name: basename(root) }; const usesProcessGroup = settings.languageId === 'ruby' && process.platform !== 'win32'; + const childEnv = settings.languageId === 'ruby' + ? { ...(settings.env ?? process.env) } + : settings.env; + if (settings.languageId === 'ruby') { + const compatDirectory = dirname(fileURLToPath(new URL('./coderuler-ruby-lsp-compat.rb', import.meta.url))); + const rubyLib = childEnv!.RUBYLIB; + const rubyOpt = childEnv!.RUBYOPT; + childEnv!.RUBYLIB = rubyLib === undefined || rubyLib === '' + ? compatDirectory + : `${compatDirectory}${delimiter}${rubyLib}`; + childEnv!.RUBYOPT = rubyOpt === undefined || rubyOpt === '' + ? '-rcoderuler-ruby-lsp-compat' + : `${rubyOpt} -rcoderuler-ruby-lsp-compat`; + } const child = spawn(settings.command, [...(settings.args ?? [])], { cwd: root, - env: settings.env, + env: childEnv, shell: process.platform === 'win32' && settings.languageId === 'typescript', detached: usesProcessGroup, stdio: ['pipe', 'pipe', 'pipe'], @@ -277,7 +292,6 @@ export async function createLspBackend( let closePromise: Promise | undefined; let connection: ProtocolConnection | undefined; let capabilities: ServerCapabilities | undefined; - let rubyPositionNeedsSurrogateAdjustment = false; const controller = new AbortController(); const documentPromises = new Map>(); const symbolPromises = new Map>(); @@ -474,11 +488,6 @@ export async function createLspBackend( workspaceFolders: [workspaceFolder], })); capabilities = initializeResult.capabilities; - // Ruby LSP 0.26.11 counts a preceding surrogate pair as one cursor unit. - rubyPositionNeedsSurrogateAdjustment = - settings.languageId === 'ruby' && - initializeResult.serverInfo?.name === 'Ruby LSP' && - initializeResult.serverInfo.version === '0.26.11'; await notify('initialized', () => connection!.sendNotification(InitializedNotification.type, {})); initialized = true; if (readiness !== undefined) await bounded('$/progress', () => readiness.promise); @@ -562,25 +571,9 @@ export async function createLspBackend( return documentForPath(canonicalPathFromUri(uri), includeDependencies); }; - const positionForTarget = (document: Document, line: number, column: number): Position => { - const position = document.index.position(line, column); - if (!rubyPositionNeedsSurrogateAdjustment) return position; - const start = document.index.lineStarts[position.line]!; - const end = start + position.character; - let surrogatePairs = 0; - for (let index = start; index + 1 < end; index += 1) { - const code = document.text.charCodeAt(index); - const next = document.text.charCodeAt(index + 1); - if (code >= 0xd800 && code <= 0xdbff && next >= 0xdc00 && next <= 0xdfff) { - surrogatePairs += 1; - index += 1; - } - } - if (surrogatePairs === 0) return position; - const character = position.character + surrogatePairs; - const lineLength = document.index.lineEnds[position.line]! - start; - return character > lineLength ? position : { ...position, character }; - }; + const positionForTarget = (document: Document, line: number, column: number): Position => + document.index.position(line, column); + const filterLocations = (result: unknown, includeDependencies: boolean): readonly Location[] => normalizeLocations(result).filter(location => isAllowedUri(location.uri, includeDependencies)); @@ -695,6 +688,9 @@ export async function createLspBackend( }; } } + if (settings.languageId === 'ruby' && rangeStrictlyContains(item.range, item.selectionRange)) { + return { ...declarationFromRange(document, item.range), range: item.range }; + } const documentSymbolProvider = capabilities?.documentSymbolProvider; if (documentSymbolProvider !== undefined && documentSymbolProvider !== false) { const symbols = await symbolQuery(document, includeDependencies); @@ -789,11 +785,42 @@ export async function createLspBackend( } return sortCandidates(astCandidates); }; + const supportsNativeCallHierarchy = + capabilities?.callHierarchyProvider !== undefined && capabilities.callHierarchyProvider !== false; + const rubyCallGraph = settings.languageId === 'ruby' && !supportsNativeCallHierarchy + ? createRubyCallGraph({ + project, + root, + signal: controller.signal, + timeoutMs, + ...(settings.env === undefined ? {} : { env: settings.env }), + documentForPath: async (filepath, includeDependencies) => { + const document = await documentForPath(filepath, includeDependencies); + return { + uri: document.uri, + text: document.text, + lineCount: document.index.lineCount, + position: (line, column) => document.index.position(line, column), + offset: position => document.index.offset(position), + }; + }, + resolveDefinitions: async (uri, position, includeDependencies) => { + const document = await documentForUri(uri, includeDependencies); + const correctedPosition = positionForTarget(document, position.line + 1, position.character + 1); + const result = await request(DefinitionRequest.type, 'textDocument/definition', { + textDocument: { uri: document.uri }, + position: correctedPosition, + }, 'definitionProvider'); + return filterLocations(result, includeDependencies); + }, + }) + : undefined; const prepareRoots = async ( target: LspTarget, includeDependencies: boolean, ): Promise => { + if (rubyCallGraph !== undefined) return rubyCallGraph.roots(target, includeDependencies); assertCapability('textDocument/prepareCallHierarchy', 'callHierarchyProvider'); const document = await documentForPath(target.filepath, includeDependencies); let position: Position; @@ -842,9 +869,28 @@ export async function createLspBackend( for (let cursor = 0; cursor < queue.length; cursor += 1) { const current = queue[cursor]!; if (current.depth >= options.depth) continue; - const response: readonly (CallHierarchyIncomingCall | CallHierarchyOutgoingCall)[] = direction === 'callers' - ? (await request(CallHierarchyIncomingCallsRequest.type, 'callHierarchy/incomingCalls', { item: current.item }, 'callHierarchyProvider')) ?? [] - : (await request(CallHierarchyOutgoingCallsRequest.type, 'callHierarchy/outgoingCalls', { item: current.item }, 'callHierarchyProvider')) ?? []; + let response: readonly (CallHierarchyIncomingCall | CallHierarchyOutgoingCall)[]; + if (rubyCallGraph !== undefined) { + if (direction === 'callers') { + response = await rubyCallGraph.incoming(current.item, includeDependencies); + } else { + response = await rubyCallGraph.outgoing(current.item, includeDependencies); + } + } else if (direction === 'callers') { + response = (await request( + CallHierarchyIncomingCallsRequest.type, + 'callHierarchy/incomingCalls', + { item: current.item }, + 'callHierarchyProvider', + )) ?? []; + } else { + response = (await request( + CallHierarchyOutgoingCallsRequest.type, + 'callHierarchy/outgoingCalls', + { item: current.item }, + 'callHierarchyProvider', + )) ?? []; + } for (const edge of response) { const item = direction === 'callers' ? (edge as CallHierarchyIncomingCall).from @@ -951,7 +997,10 @@ export async function createLspBackend( if (closePromise !== undefined) return closePromise; closing = true; controller.abort(new Error('LSP session is closed')); - closePromise = cleanup(initialized && terminalError === undefined); + closePromise = Promise.all([ + cleanup(initialized && terminalError === undefined), + rubyCallGraph?.close() ?? Promise.resolve(), + ]).then(() => undefined); return closePromise; }; diff --git a/src/adapter/lsp/coderuler-ruby-lsp-compat.rb b/src/adapter/lsp/coderuler-ruby-lsp-compat.rb new file mode 100644 index 0000000..11cb0d3 --- /dev/null +++ b/src/adapter/lsp/coderuler-ruby-lsp-compat.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +module CodeRulerRubyLspCompat + VERSION = "0.26.11" + + module DocumentCoordinates + def find_index_by_position(start_pos, end_pos = nil) + start_index, end_index = super + prefix = @coderuler_utf16_prefix + + unless @coderuler_utf16_prefix_version == version + prefix = [0] + source.each_codepoint do |codepoint| + prefix << prefix[-1] + (codepoint > 0xFFFF ? 2 : 1) + end + @coderuler_utf16_prefix = prefix + @coderuler_utf16_prefix_version = version + end + + [prefix[start_index], end_index && prefix[end_index]] + end + end + + module CommonRanges + def range_from_node(node) + range_from_location(node.location) + end + + def range_from_location(location) + return super unless location.is_a?(Prism::Location) + + ::RubyLsp::Interface::Range.new( + start: ::RubyLsp::Interface::Position.new( + line: location.start_line - 1, + character: CodeRulerRubyLspCompat.utf16_column(location, :start), + ), + end: ::RubyLsp::Interface::Position.new( + line: location.end_line - 1, + character: CodeRulerRubyLspCompat.utf16_column(location, :end), + ), + ) + end + end + + module RequestPositions + private + + def cover?(location, position) + covers_position?(location, position) + end + + def covers_position?(location, position) + return false unless location + + start_line = location.start_line - 1 + end_line = location.end_line - 1 + line = position[:line] + character = position[:character] + + (start_line < line || (start_line == line && CodeRulerRubyLspCompat.utf16_column(location, :start) <= character)) && + (end_line > line || (end_line == line && CodeRulerRubyLspCompat.utf16_column(location, :end) >= character)) + end + end + + def self.utf16_column(location, edge) + location.public_send("#{edge}_code_units_column", Encoding::UTF_16LE) + end + + def self.activate + return unless defined?(::RubyLsp::VERSION) && ::RubyLsp::VERSION == VERSION + return if @activated + + @activated = true + ::RubyLsp::RubyDocument.prepend(DocumentCoordinates) + ::RubyLsp::Requests::Support::Common.prepend(CommonRanges) + ::RubyLsp::Requests::Request.prepend(RequestPositions) + end + + module RequireHook + def require(feature) + result = super + CodeRulerRubyLspCompat.activate if feature == "ruby_lsp/internal" + result + end + + private :require + end +end + +Kernel.prepend(CodeRulerRubyLspCompat::RequireHook) diff --git a/src/adapter/lsp/ruby-call-graph.ts b/src/adapter/lsp/ruby-call-graph.ts new file mode 100644 index 0000000..b2d05cd --- /dev/null +++ b/src/adapter/lsp/ruby-call-graph.ts @@ -0,0 +1,505 @@ +import { spawn } from 'node:child_process'; +import { extname, isAbsolute, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + SymbolKind, + type CallHierarchyIncomingCall, + type CallHierarchyItem, + type CallHierarchyOutgoingCall, + type Location, + type Position, + type Range, +} from 'vscode-languageserver-protocol'; +import type { LspTarget } from '../../app/rule.js'; +import type { Project } from '../../app/ports.js'; + +interface ParsedMethod { + readonly name: string; + readonly range: Range; + readonly selectionRange: Range; +} + +interface ParsedCall { + readonly name: string; + readonly range: Range; + readonly owner: number | null; + readonly attributeWrite: boolean; +} + +interface ParsedDocument { + readonly uri: string; + readonly methods: readonly ParsedMethod[]; + readonly calls: readonly ParsedCall[]; +} + +interface SourceDocument { + readonly uri: string; + readonly text: string; + readonly lineCount: number; + position(line: number, column: number): Position; + offset(position: Position): number; +} + +interface HelperFile { + readonly uri: string; + readonly source: string; +} + +interface RubyCallGraphOptions { + readonly project: Project; + readonly root: string; + readonly signal: AbortSignal; + readonly timeoutMs: number; + readonly env?: NodeJS.ProcessEnv; + readonly documentForPath: (filepath: string, includeDependencies: boolean) => Promise; + readonly resolveDefinitions: ( + uri: string, + position: Position, + includeDependencies: boolean, + ) => Promise; +} + +interface RubyCallGraph { + roots(target: LspTarget, includeDependencies: boolean): Promise; + incoming(item: CallHierarchyItem, includeDependencies: boolean): Promise; + outgoing(item: CallHierarchyItem, includeDependencies: boolean): Promise; + close(): Promise; +} + +const MAX_STDERR_BYTES = 8 * 1024; +const HELPER_KILL_GRACE_MS = 250; + +type HelperRequest = + | { readonly action: 'parse'; readonly files: readonly HelperFile[] } + | { readonly action: 'dependencies' }; + +function describe(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function comparePosition(left: Position, right: Position): number { + return left.line - right.line || left.character - right.character; +} + +function rangeContainsPosition(range: Range, position: Position): boolean { + return comparePosition(range.start, position) <= 0 && comparePosition(position, range.end) < 0; +} + +function sameRange(left: Range, right: Range): boolean { + return comparePosition(left.start, right.start) === 0 && comparePosition(left.end, right.end) === 0; +} + +function isPosition(value: unknown): value is Position { + if ( + typeof value !== 'object' || value === null || !('line' in value) || !('character' in value) || + typeof value.line !== 'number' || typeof value.character !== 'number' + ) return false; + return Number.isSafeInteger(value.line) && Number.isSafeInteger(value.character); +} + +function isRange(value: unknown): value is Range { + if (typeof value !== 'object' || value === null || !('start' in value) || !('end' in value)) return false; + return isPosition(value.start) && isPosition(value.end); +} + +function parsedDocument(value: unknown): ParsedDocument { + if ( + typeof value !== 'object' || value === null || + !('uri' in value) || typeof value.uri !== 'string' || + !('methods' in value) || !Array.isArray(value.methods) || + !('calls' in value) || !Array.isArray(value.calls) + ) throw new Error('Ruby call graph helper returned an invalid document'); + const methods = value.methods.map((method: unknown) => { + if ( + typeof method !== 'object' || method === null || !('name' in method) || typeof method.name !== 'string' || + !('range' in method) || !isRange(method.range) || + !('selectionRange' in method) || !isRange(method.selectionRange) + ) throw new Error(`Ruby call graph helper returned an invalid method in '${value.uri}'`); + return { name: method.name, range: method.range, selectionRange: method.selectionRange }; + }); + const calls = value.calls.map((call: unknown) => { + if ( + typeof call !== 'object' || call === null || !('name' in call) || typeof call.name !== 'string' || + !('range' in call) || !isRange(call.range) || + !('owner' in call) || (call.owner !== null && (typeof call.owner !== 'number' || !Number.isSafeInteger(call.owner))) || + !('attributeWrite' in call) || typeof call.attributeWrite !== 'boolean' + ) throw new Error(`Ruby call graph helper returned an invalid call in '${value.uri}'`); + return { name: call.name, range: call.range, owner: call.owner, attributeWrite: call.attributeWrite }; + }); + return { uri: value.uri, methods, calls }; +} + +function helperResponse(value: string): unknown { + try { + return JSON.parse(value) as unknown; + } catch (error) { + throw new Error('Ruby call graph helper returned invalid JSON', { cause: error }); + } +} + +function itemForMethod(document: ParsedDocument, method: ParsedMethod): CallHierarchyItem { + return { + name: method.name, + kind: SymbolKind.Method, + uri: document.uri, + range: method.range, + selectionRange: method.selectionRange, + }; +} + +function methodKey(uri: string, selectionRange: Range): string { + let source = uri; + try { + const parsed = new URL(uri); + if (parsed.protocol === 'file:') source = resolve(fileURLToPath(parsed)); + } catch { + // Keep malformed or non-file URIs distinct. + } + return `${source}\0${selectionRange.start.line}:${selectionRange.start.character}-${selectionRange.end.line}:${selectionRange.end.character}`; +} + +export function createRubyCallGraph(options: RubyCallGraphOptions): RubyCallGraph { + const { project, root, signal, timeoutMs } = options; + const helperPath = fileURLToPath(new URL('./ruby-calls.rb', import.meta.url)); + const parsePromises = new Map>(); + const definitionPromises = new Map>(); + const activeHelpers = new Set>(); + let helperEnvironment: Promise<{ readonly env: NodeJS.ProcessEnv | undefined; readonly bundled: boolean }> | undefined; + let projectSources: Promise | undefined; + let dependencySources: Promise | undefined; + + const environmentForHelper = (): Promise<{ readonly env: NodeJS.ProcessEnv | undefined; readonly bundled: boolean }> => { + helperEnvironment ??= (async () => { + const baseEnvironment = options.env ?? process.env; + const composed = await project.stat('.ruby-lsp/Gemfile'); + if (composed?.kind === 'file') { + const env = { ...baseEnvironment, BUNDLE_GEMFILE: resolve(root, '.ruby-lsp/Gemfile') }; + return { env, bundled: true }; + } + if (baseEnvironment.BUNDLE_GEMFILE !== undefined) { + return { env: options.env, bundled: true }; + } + const projectGemfile = await project.stat('Gemfile'); + if (projectGemfile?.kind === 'file') { + const env = { ...baseEnvironment, BUNDLE_GEMFILE: resolve(root, 'Gemfile') }; + return { env, bundled: true }; + } + return { env: options.env, bundled: false }; + })(); + return helperEnvironment; + }; + + const runHelper = async (request: HelperRequest): Promise => { + if (signal.aborted) throw new Error('Ruby call graph helper was aborted', { cause: signal.reason }); + const environment = await environmentForHelper(); + if (signal.aborted) throw new Error('Ruby call graph helper was aborted', { cause: signal.reason }); + const args = environment.bundled ? ['-rbundler/setup', helperPath] : [helperPath]; + const child = spawn('ruby', args, { + cwd: root, + env: environment.env, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + const result = Promise.withResolvers(); + activeHelpers.add(result.promise); + void result.promise.then( + () => activeHelpers.delete(result.promise), + () => activeHelpers.delete(result.promise), + ); + { + const stdout: Buffer[] = []; + let stderr = Buffer.alloc(0); + let failure: Error | undefined; + let settled = false; + let killTimer: NodeJS.Timeout | undefined; + const timeout = setTimeout(() => terminate(new Error(`Ruby call graph helper timed out after ${timeoutMs}ms`)), timeoutMs); + const abort = (): void => terminate(new Error('Ruby call graph helper was aborted', { cause: signal.reason })); + const cleanUp = (): void => { + clearTimeout(timeout); + clearTimeout(killTimer); + signal.removeEventListener('abort', abort); + }; + const finish = (error?: Error, value?: unknown): void => { + if (settled) return; + settled = true; + cleanUp(); + if (error !== undefined) result.reject(error); + else result.resolve(value); + }; + const terminate = (error: Error): void => { + if (failure === undefined) failure = error; + if (child.exitCode !== null || child.signalCode !== null) return; + try { + child.kill('SIGTERM'); + } catch { + // The close event reports the final process state. + } + killTimer ??= setTimeout(() => { + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + }, HELPER_KILL_GRACE_MS); + }; + + signal.addEventListener('abort', abort, { once: true }); + child.stdout!.on('data', (chunk: Buffer | string) => { + stdout.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + child.stderr!.on('data', (chunk: Buffer | string) => { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + stderr = Buffer.concat([stderr, bytes]).subarray(-MAX_STDERR_BYTES); + }); + child.once('error', error => { + const processError = new Error(`Ruby call graph helper failed to start: ${describe(error)}`, { cause: error }); + if (child.pid === undefined) finish(processError); + else terminate(processError); + }); + child.once('close', (code, processSignal) => { + if (failure !== undefined) { + finish(failure); + return; + } + if (code !== 0) { + const details = stderr.toString('utf8').trim(); + finish(new Error( + details === '' + ? `Ruby call graph helper exited with code ${String(code)} and signal ${String(processSignal)}` + : `Ruby call graph helper exited with code ${String(code)} and signal ${String(processSignal)}\n${details}`, + )); + return; + } + try { + finish(undefined, helperResponse(Buffer.concat(stdout).toString('utf8'))); + } catch (error) { + finish(error instanceof Error ? error : new Error(describe(error), { cause: error })); + } + }); + child.stdin!.once('error', error => terminate(new Error(`Could not send data to Ruby call graph helper: ${describe(error)}`, { cause: error }))); + child.stdin!.end(JSON.stringify(request)); + } + return result.promise; + }; + + const parseDocuments = (sources: readonly SourceDocument[]): Promise => { + const unique = new Map(sources.map(source => [source.uri, source])); + const pending: { + readonly source: SourceDocument; + readonly resolve: (document: ParsedDocument) => void; + readonly reject: (error: unknown) => void; + }[] = []; + for (const source of unique.values()) { + if (parsePromises.has(source.uri)) continue; + const deferred = Promise.withResolvers(); + parsePromises.set(source.uri, deferred.promise); + pending.push({ source, resolve: deferred.resolve, reject: deferred.reject }); + } + + if (pending.length > 0) { + const batch = (async (): Promise> => { + const response = await runHelper({ + action: 'parse', + files: pending.map(({ source }) => ({ uri: source.uri, source: source.text })), + }); + if ( + typeof response !== 'object' || response === null || + !('documents' in response) || !Array.isArray(response.documents) + ) throw new Error('Ruby call graph helper returned an invalid parse response'); + const documents = response.documents.map(parsedDocument); + const byUri = new Map(documents.map(document => [document.uri, document])); + for (const { source } of pending) { + if (!byUri.has(source.uri)) throw new Error(`Ruby call graph helper omitted '${source.uri}'`); + } + return byUri; + })(); + void batch.then( + documents => { + for (const entry of pending) entry.resolve(documents.get(entry.source.uri)!); + }, + error => { + for (const entry of pending) entry.reject(error); + }, + ); + } + + return Promise.all([...unique.keys()].map(uri => parsePromises.get(uri)!)); + }; + + const parsedForPath = async (filepath: string, includeDependencies: boolean): Promise<{ + readonly source: SourceDocument; + readonly parsed: ParsedDocument; + }> => { + const source = await options.documentForPath(filepath, includeDependencies); + const [parsed] = await parseDocuments([source]); + return { source, parsed: parsed! }; + }; + + const sourceForItem = async (item: CallHierarchyItem, includeDependencies: boolean): Promise<{ + readonly parsed: ParsedDocument; + readonly method: ParsedMethod; + } | undefined> => { + let filepath: string; + try { + const uri = new URL(item.uri); + if (uri.protocol !== 'file:') return undefined; + filepath = fileURLToPath(uri); + } catch { + return undefined; + } + const { parsed } = await parsedForPath(filepath, includeDependencies); + const method = parsed.methods.find(candidate => sameRange(candidate.selectionRange, item.selectionRange)); + return method === undefined ? undefined : { parsed, method }; + }; + + const definitionsAt = (uri: string, position: Position, includeDependencies: boolean): Promise => { + const key = `${uri}\0${position.line}:${position.character}\0${includeDependencies ? '1' : '0'}`; + let pending = definitionPromises.get(key); + if (pending === undefined) { + pending = Promise.resolve().then(() => options.resolveDefinitions(uri, position, includeDependencies)); + definitionPromises.set(key, pending); + } + return pending; + }; + + const methodsForLocations = async ( + locations: readonly Location[], + includeDependencies: boolean, + ): Promise => { + const items = new Map(); + for (const location of locations) { + let filepath: string; + try { + const uri = new URL(location.uri); + if (uri.protocol !== 'file:') continue; + filepath = fileURLToPath(uri); + } catch { + continue; + } + // Ruby LSP can resolve builtin methods to RBS declarations, which Prism cannot parse. + if (extname(filepath) !== '.rb') continue; + const { parsed } = await parsedForPath(filepath, includeDependencies); + for (const method of parsed.methods) { + if (!sameRange(method.selectionRange, location.range)) continue; + const item = itemForMethod(parsed, method); + items.set(methodKey(item.uri, item.selectionRange), item); + } + } + return [...items.values()]; + }; + + const resolveCall = async ( + document: ParsedDocument, + call: ParsedCall, + includeDependencies: boolean, + ): Promise => { + const locations = await definitionsAt(document.uri, call.range.start, includeDependencies); + const methods = await methodsForLocations(locations, includeDependencies); + return call.attributeWrite + ? methods.filter(method => method.name === call.name) + : methods; + }; + const documentsForScan = async (includeDependencies: boolean): Promise => { + projectSources ??= project.glob('**/*.rb').then(paths => Promise.all( + [...new Set(paths)].sort().map(filepath => options.documentForPath(filepath, false)), + )); + const projectDocuments = await projectSources; + if (!includeDependencies) return projectDocuments; + + dependencySources ??= (async () => { + const response = await runHelper({ action: 'dependencies' }); + if ( + typeof response !== 'object' || response === null || + !('paths' in response) || !Array.isArray(response.paths) + ) throw new Error('Ruby call graph helper returned an invalid dependency response'); + const paths: string[] = []; + for (const path of response.paths) { + if (typeof path !== 'string' || !isAbsolute(path) || extname(path) !== '.rb') { + throw new Error('Ruby call graph helper returned an invalid dependency path'); + } + paths.push(path); + } + return Promise.all([...new Set(paths)].sort().map(filepath => options.documentForPath(filepath, true))); + })(); + return [...projectDocuments, ...await dependencySources]; + }; + + const roots = async (target: LspTarget, includeDependencies: boolean): Promise => { + const { source, parsed } = await parsedForPath(target.filepath, includeDependencies); + if (target.column === undefined) { + if (!Number.isSafeInteger(target.line) || target.line < 1 || target.line > source.lineCount) { + throw new Error(`LSP line ${target.line} is outside the document`); + } + const line = target.line - 1; + const candidates = parsed.methods.filter(method => method.range.start.line <= line && method.range.end.line >= line); + candidates.sort((left, right) => { + const leftSize = source.offset(left.range.end) - source.offset(left.range.start); + const rightSize = source.offset(right.range.end) - source.offset(right.range.start); + return leftSize - rightSize || comparePosition(left.range.start, right.range.start); + }); + return candidates[0] === undefined ? [] : [itemForMethod(parsed, candidates[0])]; + } + + const position = source.position(target.line, target.column); + const calls = parsed.calls.filter(call => rangeContainsPosition(call.range, position)); + calls.sort((left, right) => + source.offset(left.range.end) - source.offset(left.range.start) - + (source.offset(right.range.end) - source.offset(right.range.start)), + ); + const selectedCall = calls[0]; + if (selectedCall !== undefined) return resolveCall(parsed, selectedCall, includeDependencies); + + const methods = parsed.methods.filter(method => rangeContainsPosition(method.selectionRange, position)); + const candidates = methods.length > 0 + ? methods + : parsed.methods.filter(method => rangeContainsPosition(method.range, position)); + candidates.sort((left, right) => + source.offset(left.range.end) - source.offset(left.range.start) - + (source.offset(right.range.end) - source.offset(right.range.start)), + ); + return candidates[0] === undefined ? [] : [itemForMethod(parsed, candidates[0])]; + }; + + const outgoing = async ( + item: CallHierarchyItem, + includeDependencies: boolean, + ): Promise => { + const owner = await sourceForItem(item, includeDependencies); + if (owner === undefined) return []; + const ownerIndex = owner.parsed.methods.indexOf(owner.method); + const calls = owner.parsed.calls.filter(call => call.owner === ownerIndex); + const edges = new Map(); + for (const call of calls) { + for (const target of await resolveCall(owner.parsed, call, includeDependencies)) { + const key = methodKey(target.uri, target.selectionRange); + edges.set(key, { to: target, fromRanges: [call.range] }); + } + } + return [...edges.values()]; + }; + + const incoming = async ( + item: CallHierarchyItem, + includeDependencies: boolean, + ): Promise => { + const target = await sourceForItem(item, includeDependencies); + if (target === undefined) return []; + const targetKey = methodKey(target.parsed.uri, target.method.selectionRange); + const documents = await documentsForScan(includeDependencies); + const parsedDocuments = await parseDocuments(documents); + const callers = new Map(); + for (const document of parsedDocuments) { + for (const call of document.calls) { + if (call.name !== target.method.name || call.owner === null) continue; + const owner = document.methods[call.owner]; + if (owner === undefined) continue; + const locations = await definitionsAt(document.uri, call.range.start, includeDependencies); + const resolvesToTarget = locations.some(location => methodKey(location.uri, location.range) === targetKey); + if (!resolvesToTarget) continue; + const caller = itemForMethod(document, owner); + callers.set(methodKey(caller.uri, caller.selectionRange), { from: caller, fromRanges: [call.range] }); + } + } + return [...callers.values()]; + }; + const close = async (): Promise => { + await Promise.allSettled([...activeHelpers]); + }; + return { roots, incoming, outgoing, close }; +} diff --git a/src/adapter/lsp/ruby-calls.rb b/src/adapter/lsp/ruby-calls.rb new file mode 100755 index 0000000..a1d5b5b --- /dev/null +++ b/src/adapter/lsp/ruby-calls.rb @@ -0,0 +1,144 @@ +#!/usr/bin/env ruby + +require "json" +require "rbconfig" +require "rubygems" + +gemfile = ENV["BUNDLE_GEMFILE"] +if gemfile + require "bundler/setup" +end +require "prism" + +class RubyCallDocument + def initialize(source) + @source = source + @methods = [] + @calls = [] + @utf16_offsets = Array.new(source.bytesize + 1, 0) + @line_starts = [0] + byte_offset = 0 + utf16_offset = 0 + + source.each_char do |character| + byte_offset += character.bytesize + utf16_offset += character.ord > 0xffff ? 2 : 1 + @utf16_offsets[byte_offset] = utf16_offset + @line_starts << byte_offset if character == "\n" + end + end + + def parse(uri) + result = Prism.parse(@source) + unless result.errors.empty? + details = result.errors.map(&:message).join("; ") + raise ArgumentError, "cannot parse #{uri}: #{details}" + end + + visit(result.value, nil) + { uri: uri, methods: @methods, calls: @calls } + end + + private + + def visit(node, owner) + case node + when Prism::DefNode + method_owner = @methods.length + @methods << { + name: node.name.to_s, + range: range(node.location), + selectionRange: range(node.name_loc) + } + node.compact_child_nodes.each do |child| + child_owner = child.equal?(node.receiver) ? owner : method_owner + visit(child, child_owner) + end + when Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode + node.compact_child_nodes.each { |child| visit(child, nil) } + when Prism::CallNode + call_location = node.message_loc || node.call_operator_loc || node.location + @calls << { name: node.name.to_s, range: range(call_location), owner: owner, attributeWrite: node.attribute_write? } + node.compact_child_nodes.each { |child| visit(child, owner) } + when Prism::SuperNode + name = owner.nil? ? "super" : @methods[owner][:name] + @calls << { name: name, range: range(node.keyword_loc), owner: owner, attributeWrite: false } + node.compact_child_nodes.each { |child| visit(child, owner) } + when Prism::ForwardingSuperNode + name = owner.nil? ? "super" : @methods[owner][:name] + @calls << { name: name, range: range(node.location.copy(length: 5)), owner: owner, attributeWrite: false } + node.compact_child_nodes.each { |child| visit(child, owner) } + else + node.compact_child_nodes.each { |child| visit(child, owner) } + end + end + + def range(location) + { start: position(location.start_offset), end: position(location.end_offset) } + end + + def position(byte_offset) + following_line = @line_starts.bsearch_index { |line_start| line_start > byte_offset } + line = following_line ? following_line - 1 : @line_starts.length - 1 + line_start = @line_starts[line] + { line: line, character: @utf16_offsets[byte_offset] - @utf16_offsets[line_start] } + end +end + +def request_files(request) + files = request["files"] + raise ArgumentError, "parse action requires a files array" unless files.is_a?(Array) + + files.map.with_index do |file, index| + unless file.is_a?(Hash) && file["uri"].is_a?(String) && file["source"].is_a?(String) + raise ArgumentError, "files[#{index}] requires string uri and source fields" + end + file + end +end + +def ruby_sources(root) + return [] unless root && File.directory?(root) + + Dir.glob(File.join(root, "**", "*.rb"), File::FNM_DOTMATCH) + .select { |path| File.file?(path) } + .map { |path| File.expand_path(path) } +end + +def dependency_paths + specs = if ENV["BUNDLE_GEMFILE"] + require "bundler" + Bundler.load.specs + else + Gem.loaded_specs.values + end + + roots = specs.flat_map(&:full_require_paths) + roots << RbConfig::CONFIG["rubylibdir"] + roots.flat_map { |root| ruby_sources(root) }.uniq.sort +end + +begin + STDIN.binmode + STDOUT.binmode + request = JSON.parse(STDIN.read.force_encoding(Encoding::UTF_8)) + raise ArgumentError, "request must be a JSON object" unless request.is_a?(Hash) + + response = case request["action"] + when "parse" + documents = request_files(request).map do |file| + RubyCallDocument.new(file["source"]).parse(file["uri"]) + end + { documents: documents } + when "dependencies" + { paths: dependency_paths } + else + raise ArgumentError, "action must be parse or dependencies" + end + + STDOUT.write(JSON.generate(response).encode(Encoding::UTF_8)) + STDOUT.write("\n".encode(Encoding::UTF_8)) +rescue StandardError, ScriptError => error + warn "ruby-calls: #{error.class}: #{error.message}" + exit 1 +end diff --git a/tests/fixtures/ruby-lsp/call-expected.json b/tests/fixtures/ruby-lsp/call-expected.json new file mode 100644 index 0000000..3181273 --- /dev/null +++ b/tests/fixtures/ruby-lsp/call-expected.json @@ -0,0 +1,287 @@ +{ + "originCalleesDepth2": [ + { + "name": "left", + "path": "lib/call_graph.rb", + "depth": 1, + "source": "def left\n leaf\n end", + "lines": { "start": 9, "end": 11 } + }, + { + "name": "right", + "path": "lib/call_graph.rb", + "depth": 1, + "source": "def right\n leaf\n end", + "lines": { "start": 13, "end": 15 } + }, + { + "name": "leaf", + "path": "lib/call_graph.rb", + "depth": 2, + "source": "def leaf\n end", + "lines": { "start": 17, "end": 18 } + } + ], + "leafCallersDepth2": [ + { + "name": "left", + "path": "lib/call_graph.rb", + "depth": 1, + "source": "def left\n leaf\n end", + "lines": { "start": 9, "end": 11 } + }, + { + "name": "right", + "path": "lib/call_graph.rb", + "depth": 1, + "source": "def right\n leaf\n end", + "lines": { "start": 13, "end": 15 } + }, + { + "name": "origin", + "path": "lib/call_graph.rb", + "depth": 2, + "source": "def origin\n left\n right\n end", + "lines": { "start": 4, "end": 7 } + } + ], + "cycleAlphaCalleesDepth4": [ + { + "name": "beta", + "path": "lib/call_graph.rb", + "depth": 1, + "source": "def beta\n gamma\n end", + "lines": { "start": 26, "end": 28 } + }, + { + "name": "gamma", + "path": "lib/call_graph.rb", + "depth": 2, + "source": "def gamma\n alpha\n end", + "lines": { "start": 30, "end": 32 } + } + ], + "redRunCallers": [ + { + "name": "red_entry", + "path": "lib/call_graph.rb", + "depth": 1, + "source": "def red_entry\n RedWorker.new.run\n end", + "lines": { "start": 54, "end": 56 } + } + ], + "blueRunCallers": [ + { + "name": "blue_entry", + "path": "lib/call_graph.rb", + "depth": 1, + "source": "def blue_entry\n BlueWorker.new.run\n end", + "lines": { "start": 58, "end": 60 } + } + ], + "redEntryCalleesDepth2": [ + { + "name": "run", + "path": "lib/call_graph.rb", + "depth": 1, + "source": "def run\n red_task\n end", + "lines": { "start": 36, "end": 38 } + }, + { + "name": "red_task", + "path": "lib/call_graph.rb", + "depth": 2, + "source": "def red_task\n end", + "lines": { "start": 40, "end": 41 } + } + ], + "bareTargetCallers": [ + { + "name": "bare_entry", + "path": "lib/call_graph.rb", + "depth": 1, + "source": "def bare_entry\n bare_target\n end", + "lines": { "start": 64, "end": 66 } + } + ], + "localTargetCallers": [], + "singletonEntryCallees": [ + { + "name": "endless_target", + "path": "lib/call_graph.rb", + "depth": 1, + "source": "def self.endless_target = :done", + "lines": { "start": 85, "end": 85 } + } + ], + "endlessTargetCallers": [ + { + "name": "singleton_entry", + "path": "lib/call_graph.rb", + "depth": 1, + "source": "def self.singleton_entry\n endless_target\n end", + "lines": { "start": 81, "end": 83 } + } + ], + "outerCallees": [ + { + "name": "inner", + "path": "lib/call_graph.rb", + "depth": 1, + "source": "def inner\n end", + "lines": { "start": 96, "end": 97 } + } + ], + "nestedBodyLineOnlyCallees": [ + { + "name": "nested_leaf", + "path": "lib/call_graph.rb", + "depth": 1, + "source": "def nested_leaf\n end", + "lines": { "start": 99, "end": 100 } + } + ], + "blockEntryCallees": [ + { + "name": "block_leaf", + "path": "lib/call_graph.rb", + "depth": 1, + "source": "def block_leaf\n end", + "lines": { "start": 110, "end": 111 } + } + ], + "blockEntryCalleesWithDependencies": [ + { + "name": "block_leaf", + "path": "lib/call_graph.rb", + "depth": 1, + "source": "def block_leaf\n end", + "lines": { "start": 110, "end": 111 } + } + ], + "baseSaveCallers": [ + { + "name": "save", + "path": "lib/call_graph.rb", + "depth": 1, + "source": "def save(value)\n super\n end", + "lines": { "start": 120, "end": 122 } + }, + { + "name": "save", + "path": "lib/call_graph.rb", + "depth": 1, + "source": "def save(value)\n super(value)\n end", + "lines": { "start": 126, "end": 128 } + } + ], + "childSaveCallees": [ + { + "name": "save", + "path": "lib/call_graph.rb", + "depth": 1, + "source": "def save(value)\n end", + "lines": { "start": 115, "end": 116 } + } + ], + "forwardingSaveCallees": [ + { + "name": "save", + "path": "lib/call_graph.rb", + "depth": 1, + "source": "def save(value)\n end", + "lines": { "start": 115, "end": 116 } + } + ], + "ambiguousCallSiteCallees": [ + { + "name": "candidate_one_leaf", + "path": "lib/call_graph.rb", + "depth": 1, + "source": "def candidate_one_leaf\n end", + "lines": { "start": 136, "end": 137 } + }, + { + "name": "candidate_two_leaf", + "path": "lib/call_graph.rb", + "depth": 1, + "source": "def candidate_two_leaf\n end", + "lines": { "start": 145, "end": 146 } + } + ], + "unicodeCallSiteCallers": [ + { + "name": "unicode_entry", + "path": "lib/call_graph.rb", + "depth": 1, + "source": "def unicode_entry\n \"๐Ÿ˜€\"; unicode_leaf\n end", + "lines": { "start": 154, "end": 156 } + } + ], + "unicodeOffsetCallers": [ + { + "name": "c", + "path": "lib/call_graph.rb", + "depth": 1, + "source": "def c; x; end", + "lines": { "start": 199, "end": 199 } + } + ], + "dependencyCalleesDefault": [], + "dependencyCalleesOptIn": [ + { + "name": "remote_execute", + "path": "/lib/support_widget.rb", + "depth": 1, + "source": "def remote_execute\n remote_leaf\n end", + "lines": { "start": 2, "end": 4 } + }, + { + "name": "remote_leaf", + "path": "/lib/support_widget.rb", + "depth": 2, + "source": "def remote_leaf\n end", + "lines": { "start": 6, "end": 7 } + } + ], + "dependencyCallersDefault": [], + "dependencyCallersOptIn": [ + { + "name": "dependency_entry", + "path": "lib/call_graph.rb", + "depth": 1, + "source": "def dependency_entry\n SupportWidget.new.remote_execute\n end", + "lines": { "start": 163, "end": 165 } + } + ], + "dependencySinkCallersDefault": [], + "dependencySinkCallersOptIn": [ + { + "name": "remote_call_project", + "path": "/lib/support_widget.rb", + "depth": 1, + "source": "def remote_call_project\n DependencyEntrypoint.new.dependency_sink\n end", + "lines": { "start": 9, "end": 11 } + } + ], + "propertyAssignmentCallees": [ + { + "name": "rhs_helper", + "path": "lib/call_graph.rb", + "depth": 1, + "source": "def rhs_helper\n end", + "lines": { "start": 188, "end": 189 } + } + ], + "propertyGetterCallers": [ + { + "name": "read_entry", + "path": "lib/call_graph.rb", + "depth": 1, + "source": "def read_entry\n self.value\n end", + "lines": { "start": 184, "end": 186 } + } + ], + "propertyAssignmentSiteCallees": [], + "emptyNonMethodCallees": [] +} diff --git a/tests/fixtures/ruby-lsp/call_graph.rb b/tests/fixtures/ruby-lsp/call_graph.rb new file mode 100644 index 0000000..72ac03c --- /dev/null +++ b/tests/fixtures/ruby-lsp/call_graph.rb @@ -0,0 +1,201 @@ +require "support_widget" + +class GraphFlow + def origin + left + right + end + + def left + leaf + end + + def right + leaf + end + + def leaf + end +end + +class CycleFlow + def alpha + beta + end + + def beta + gamma + end + + def gamma + alpha + end +end + +class RedWorker + def run + red_task + end + + def red_task + end +end + +class BlueWorker + def run + blue_task + end + + def blue_task + end +end + +class ReceiverEntrypoints + def red_entry + RedWorker.new.run + end + + def blue_entry + BlueWorker.new.run + end +end + +class BareCalls + def bare_entry + bare_target + end + + def bare_target + end + + def local_reader + local_target = 1 + local_target + end + + def local_target + end +end + +class ShapeFlow + def self.singleton_entry + endless_target + end + + def self.endless_target = :done +end + +class ScopeFlow + def outer + inner + def nested + nested_leaf + end + end + + def inner + end + + def nested_leaf + end +end + +class BlockFlow + def block_entry + [1].each do + block_leaf + end + end + + def block_leaf + end +end + +class SuperBase + def save(value) + end +end + +class SuperChild < SuperBase + def save(value) + super + end +end + +class ForwardChild < SuperBase + def save(value) + super(value) + end +end + +class MaybeOne + def shared + self.candidate_one_leaf + end + + def candidate_one_leaf + end +end + +class MaybeTwo + def shared + self.candidate_two_leaf + end + + def candidate_two_leaf + end +end + +def ambiguous_entry(receiver) + receiver.shared +end + +class UnicodeFlow + def unicode_entry + "๐Ÿ˜€"; unicode_leaf + end + + def unicode_leaf + end +end + +class DependencyEntrypoint + def dependency_entry + SupportWidget.new.remote_execute + end + + def dependency_sink + end +end + +class PropertyWriteFlow + def value + getter_helper + end + + def value=(replacement) + replacement + end + + def assignment_entry + self.value = rhs_helper + end + + def read_entry + self.value + end + + def rhs_helper + end + + def getter_helper + end +end + +class Utf16Flow + "๐Ÿ˜€" + "๐Ÿงช" + + def c; x; end + def x; end +end diff --git a/tests/fixtures/ruby-lsp/project-dependency/lib/support_widget.rb b/tests/fixtures/ruby-lsp/project-dependency/lib/support_widget.rb index 5e980e4..26b707d 100644 --- a/tests/fixtures/ruby-lsp/project-dependency/lib/support_widget.rb +++ b/tests/fixtures/ruby-lsp/project-dependency/lib/support_widget.rb @@ -1 +1,12 @@ -class SupportWidget; end +class SupportWidget + def remote_execute + remote_leaf + end + + def remote_leaf + end + + def remote_call_project + DependencyEntrypoint.new.dependency_sink + end +end diff --git a/tests/lsp-process.test.ts b/tests/lsp-process.test.ts index f73c451..0f64e1f 100644 --- a/tests/lsp-process.test.ts +++ b/tests/lsp-process.test.ts @@ -693,24 +693,23 @@ if (process.platform !== 'win32') { }); } -test('Ruby positive-depth call hierarchy rejects before resolving either target shape', { timeout: 10_000 }, async () => { - const fixture = await makeProject('puts 1\n', 'main.rb'); - const progressPath = join(fixture.root, 'indexing.progress'); - const releasePath = join(fixture.root, 'unused-release'); +test('non-Ruby positive-depth call hierarchy rejects when the provider is unavailable', { timeout: 10_000 }, async () => { + const fixture = await makeProject(); + const requestMarker = join(fixture.root, 'definition.requested'); const exitPath = join(fixture.root, 'child.exited'); let backend: LspBackend | undefined; try { backend = await createLspBackend( fixture.project, new AbortController().signal, - rubyServerOptions( + serverOptions( process.execPath, - nodeArgs(rubyProgressPeerScript( + nodeArgs(protocolPeerScript( fixture.pidPath, exitPath, - progressPath, - releasePath, - 'early', + 'hang-definition', + requestMarker, + pathToFileURL(fixture.sourcePath).href, )), 2_000, ), @@ -718,8 +717,8 @@ test('Ruby positive-depth call hierarchy rejects before resolving either target for (const method of ['callers', 'callees'] as const) { for (const target of [ - { filepath: 'main.rb', line: 1, column: 1 }, - { filepath: 'main.rb', line: 1 }, + { filepath: 'main.go', line: 3, column: 6 }, + { filepath: 'main.go', line: 3 }, ]) { await assert.rejects( backend[method](target, { depth: 1 }), diff --git a/tests/ruby-lsp.test.ts b/tests/ruby-lsp.test.ts index af81bf8..5aa700b 100644 --- a/tests/ruby-lsp.test.ts +++ b/tests/ruby-lsp.test.ts @@ -7,7 +7,14 @@ import { test } from 'node:test'; import { createProject } from '../dist/adapter/filesystem/project.js'; import { createLspBackend } from '../dist/adapter/lsp/backend.js'; import { createRunLsp } from '../dist/app/lsp.js'; -import type { DocumentSymbol, Location, SymbolInformation, WorkspaceSymbol } from '../dist/index.js'; +import type { + DocumentSymbol, + Location, + LspCall, + LspTarget, + SymbolInformation, + WorkspaceSymbol, +} from '../dist/index.js'; import { createSandbox } from './support.ts'; import { createRubyLspFixture } from './ruby-lsp-support.ts'; @@ -27,6 +34,47 @@ interface RubySymbolView { readonly line: number | null; } +interface RubyCallView { + readonly name: string; + readonly path: string; + readonly depth: number; + readonly source: string; + readonly lines: { readonly start: number; readonly end: number }; +} + +interface RubyCallGoldens { + readonly originCalleesDepth2: readonly RubyCallView[]; + readonly leafCallersDepth2: readonly RubyCallView[]; + readonly cycleAlphaCalleesDepth4: readonly RubyCallView[]; + readonly redRunCallers: readonly RubyCallView[]; + readonly blueRunCallers: readonly RubyCallView[]; + readonly redEntryCalleesDepth2: readonly RubyCallView[]; + readonly bareTargetCallers: readonly RubyCallView[]; + readonly localTargetCallers: readonly RubyCallView[]; + readonly singletonEntryCallees: readonly RubyCallView[]; + readonly endlessTargetCallers: readonly RubyCallView[]; + readonly outerCallees: readonly RubyCallView[]; + readonly nestedBodyLineOnlyCallees: readonly RubyCallView[]; + readonly blockEntryCallees: readonly RubyCallView[]; + readonly blockEntryCalleesWithDependencies: readonly RubyCallView[]; + readonly baseSaveCallers: readonly RubyCallView[]; + readonly childSaveCallees: readonly RubyCallView[]; + readonly forwardingSaveCallees: readonly RubyCallView[]; + readonly ambiguousCallSiteCallees: readonly RubyCallView[]; + readonly unicodeCallSiteCallers: readonly RubyCallView[]; + readonly unicodeOffsetCallers: readonly RubyCallView[]; + readonly dependencyCalleesDefault: readonly RubyCallView[]; + readonly dependencyCalleesOptIn: readonly RubyCallView[]; + readonly dependencyCallersDefault: readonly RubyCallView[]; + readonly dependencyCallersOptIn: readonly RubyCallView[]; + readonly dependencySinkCallersDefault: readonly RubyCallView[]; + readonly dependencySinkCallersOptIn: readonly RubyCallView[]; + readonly emptyNonMethodCallees: readonly RubyCallView[]; + readonly propertyAssignmentCallees: readonly RubyCallView[]; + readonly propertyGetterCallers: readonly RubyCallView[]; + readonly propertyAssignmentSiteCallees: readonly RubyCallView[]; +} + function relativePath(root: string, path: string): string | undefined { const candidate = relative(root, path); return candidate === '..' || candidate.startsWith(`..${sep}`) || isAbsolute(candidate) @@ -56,6 +104,52 @@ function locationView( .sort((left, right) => left.path.localeCompare(right.path) || left.line - right.line); } +function normalizeCallPath(filepath: string, projectRoot: string, dependencyRoot: string): string { + if (!isAbsolute(filepath)) return filepath.split(sep).join('/'); + const projectPath = relativePath(projectRoot, filepath); + if (projectPath !== undefined) return projectPath; + const dependencyPath = relativePath(dependencyRoot, filepath); + if (dependencyPath !== undefined) return `/${dependencyPath}`; + throw new Error(`Ruby LSP returned a call path outside the fixture: ${filepath}`); +} + +function callView( + calls: readonly LspCall[], + projectRoot: string, + dependencyRoot: string, +): readonly RubyCallView[] { + return calls + .map(call => ({ + name: call.name, + path: normalizeCallPath(call.filepath, projectRoot, dependencyRoot), + depth: call.depth, + source: call.source, + lines: call.lines, + })) + .sort((left, right) => left.depth - right.depth + || left.path.localeCompare(right.path) + || left.lines.start - right.lines.start + || left.name.localeCompare(right.name)); +} + +function lineTarget(source: string, marker: string, occurrence = 0) { + let matched = 0; + for (const [index, line] of source.split(/\r?\n/u).entries()) { + if (!line.includes(marker)) continue; + if (matched === occurrence) return { filepath: 'lib/call_graph.rb', line: index + 1 }; + matched += 1; + } + throw new Error(`Could not find occurrence ${occurrence + 1} of '${marker}' in call_graph.rb`); +} + +function callSiteTarget(source: string, token: string, lineMarker: string) { + const target = lineTarget(source, lineMarker); + const line = source.split(/\r?\n/u)[target.line - 1]!; + const column = line.lastIndexOf(token); + if (column < 0) throw new Error(`Could not find '${token}' on '${lineMarker}'`); + return { ...target, column: column + 1 }; +} + function documentSymbolView( symbols: readonly DocumentSymbol[] | readonly SymbolInformation[], filepath: string, @@ -205,15 +299,6 @@ test('real Ruby LSP navigation matches the fixture golden', { timeout: 180_000 } message: 'LSP server does not support textDocument/implementation', }); - for (const target of [widgetTarget, { filepath: 'lib/usage.rb', line: 1 }]) { - await assert.rejects(tools.callers(target, { depth: 1 }), { - message: 'LSP server does not support textDocument/prepareCallHierarchy', - }); - await assert.rejects(tools.callees(target, { depth: 1 }), { - message: 'LSP server does not support textDocument/prepareCallHierarchy', - }); - } - assert.deepEqual(await depthZeroSession.tools.callers(widgetTarget, { depth: 0 }), []); assert.deepEqual(await depthZeroSession.tools.callees(widgetTarget, { depth: 0 }), []); assert.equal(depthZeroStarts, 0); @@ -229,3 +314,129 @@ test('real Ruby LSP navigation matches the fixture golden', { timeout: 180_000 } } } }); +test('real Ruby LSP callers and callees match the fixture golden', { timeout: 180_000 }, async () => { + const sandboxes = createSandbox('ruby-lsp-calls-test-'); + const fixture = await createRubyLspFixture(sandboxes.isolated('ruby')); + const callGraphFixture = fileURLToPath(new URL('./fixtures/ruby-lsp/call_graph.rb', import.meta.url)); + await writeFile( + join(fixture.cwd, 'lib', 'call_graph.rb'), + await readFile(callGraphFixture, 'utf8'), + ); + const project = createProject(fixture.cwd); + const dependencyRoot = join(fixture.root, 'project-dependency'); + const session = createRunLsp(project, (activeProject, signal) => createLspBackend(activeProject, signal, { + command: 'ruby-lsp', + args: [], + languageId: 'ruby', + initializationProgressToken: 'indexing-progress', + env: fixture.env, + })); + + try { + const callGoldenPath = fileURLToPath(new URL('./fixtures/ruby-lsp/call-expected.json', import.meta.url)); + const callGolden = JSON.parse(await readFile(callGoldenPath, 'utf8')) as RubyCallGoldens; + const { tools } = session; + const graphSource = await readFile(join(fixture.cwd, 'lib', 'call_graph.rb'), 'utf8'); + const graphCalls = async ( + direction: 'callers' | 'callees', + target: LspTarget, + depth: number, + includeDependencies = false, + ) => callView( + await tools[direction](target, { depth, includeDependencies }), + fixture.cwd, + dependencyRoot, + ); + const actualCallGraph: RubyCallGoldens = { + originCalleesDepth2: await graphCalls('callees', lineTarget(graphSource, 'def origin'), 2), + leafCallersDepth2: await graphCalls('callers', lineTarget(graphSource, 'def leaf'), 2), + cycleAlphaCalleesDepth4: await graphCalls('callees', lineTarget(graphSource, 'def alpha'), 4), + redRunCallers: await graphCalls('callers', lineTarget(graphSource, ' def run', 0), 1), + blueRunCallers: await graphCalls('callers', lineTarget(graphSource, ' def run', 1), 1), + redEntryCalleesDepth2: await graphCalls('callees', lineTarget(graphSource, 'def red_entry'), 2), + bareTargetCallers: await graphCalls('callers', lineTarget(graphSource, 'def bare_target'), 1), + localTargetCallers: await graphCalls('callers', lineTarget(graphSource, 'def local_target'), 1), + singletonEntryCallees: await graphCalls('callees', lineTarget(graphSource, 'def self.singleton_entry'), 1), + endlessTargetCallers: await graphCalls('callers', lineTarget(graphSource, 'def self.endless_target'), 1), + outerCallees: await graphCalls('callees', lineTarget(graphSource, 'def outer'), 1), + nestedBodyLineOnlyCallees: await graphCalls('callees', lineTarget(graphSource, ' nested_leaf'), 1), + blockEntryCallees: await graphCalls('callees', lineTarget(graphSource, 'def block_entry'), 1), + blockEntryCalleesWithDependencies: await graphCalls( + 'callees', + lineTarget(graphSource, 'def block_entry'), + 1, + true, + ), + baseSaveCallers: await graphCalls('callers', lineTarget(graphSource, 'def save(value)', 0), 1), + childSaveCallees: await graphCalls('callees', lineTarget(graphSource, 'def save(value)', 1), 1), + forwardingSaveCallees: await graphCalls('callees', lineTarget(graphSource, 'def save(value)', 2), 1), + ambiguousCallSiteCallees: await graphCalls( + 'callees', + callSiteTarget(graphSource, 'shared', 'receiver.shared'), + 1, + ), + unicodeCallSiteCallers: await graphCalls( + 'callers', + callSiteTarget(graphSource, 'unicode_leaf', '"๐Ÿ˜€"; unicode_leaf'), + 1, + ), + unicodeOffsetCallers: await graphCalls( + 'callers', + callSiteTarget(graphSource, 'x', 'def c; x; end'), + 1, + ), + dependencyCalleesDefault: await graphCalls( + 'callees', + lineTarget(graphSource, 'def dependency_entry'), + 2, + ), + dependencyCalleesOptIn: await graphCalls( + 'callees', + lineTarget(graphSource, 'def dependency_entry'), + 2, + true, + ), + dependencyCallersDefault: await graphCalls( + 'callers', + callSiteTarget(graphSource, 'remote_execute', 'SupportWidget.new.remote_execute'), + 1, + ), + dependencyCallersOptIn: await graphCalls( + 'callers', + callSiteTarget(graphSource, 'remote_execute', 'SupportWidget.new.remote_execute'), + 1, + true, + ), + dependencySinkCallersDefault: await graphCalls( + 'callers', + lineTarget(graphSource, 'def dependency_sink'), + 1, + ), + dependencySinkCallersOptIn: await graphCalls( + 'callers', + lineTarget(graphSource, 'def dependency_sink'), + 1, + true, + ), + emptyNonMethodCallees: await graphCalls('callees', lineTarget(graphSource, 'class GraphFlow'), 1), + propertyAssignmentCallees: await graphCalls( + 'callees', + lineTarget(graphSource, 'def assignment_entry'), + 1, + ), + propertyGetterCallers: await graphCalls('callers', lineTarget(graphSource, 'def value', 0), 1), + propertyAssignmentSiteCallees: await graphCalls( + 'callees', + callSiteTarget(graphSource, 'value', 'self.value = rhs_helper'), + 1, + ), + }; + assert.deepEqual(actualCallGraph, callGolden); + } finally { + try { + await session.close(); + } finally { + sandboxes.cleanup(); + } + } +}); diff --git a/website/src/content/docs/writing-rules.md b/website/src/content/docs/writing-rules.md index 94d3f60..e11e3c6 100644 --- a/website/src/content/docs/writing-rules.md +++ b/website/src/content/docs/writing-rules.md @@ -126,11 +126,11 @@ A custom function receives the selected subject and a context object. Return `pa The hooks receive `project` readers and `trace(message, details?)`. `select` also receives `context.select(...)`. Checks also receive `ruleName` and `context.ai(...)`. -`context.goLsp` provides Go-to-definition, declaration, type-definition, implementation, reference, hover, document-symbol, workspace-symbol, caller, and callee queries. `context.rubyLsp` provides Ruby definitions, references, hover, document symbols, and workspace symbols. `context.typescriptLsp` provides TypeScript definitions, type definitions, implementations, references, hover, document symbols, workspace symbols, callers, and callees; declaration lookup is not supported. LSP use is optional. Install Go and `gopls`, Shopify Ruby LSP (`ruby-lsp`), or native TypeScript 7's `tsc` on `PATH` only when a rule uses that language's helpers. CodeRuler does not install these servers. +`context.goLsp` provides Go-to-definition, declaration, type-definition, implementation, reference, hover, document-symbol, workspace-symbol, caller, and callee queries. `context.rubyLsp` provides Ruby definitions, references, hover, document symbols, workspace symbols, callers, and callees. `context.typescriptLsp` provides TypeScript definitions, type definitions, implementations, references, hover, document symbols, workspace symbols, callers, and callees; declaration lookup is not supported. LSP use is optional. Install Go and `gopls`, Shopify Ruby LSP (`ruby-lsp`), or native TypeScript 7's `tsc` on `PATH` only when a rule uses that language's helpers. Ruby caller/callee queries also need a Ruby runtime and Prism available in the Ruby LSP bundle. CodeRuler does not install these servers. -Pass file paths and positive, 1-based line and UTF-16 column coordinates. A column counts UTF-16 code units. Navigation and hover use column 1 when omitted. Caller and callee queries (Go and TypeScript) use the enclosing named function or method when the column is omitted. Their required `depth` is a nonnegative integer; depth 1 returns direct neighbors. Set `includeDependencies: true` to include available dependency or standard-library source outside the project; the default is `false`. Ruby dependency definitions are excluded by default and can be requested with `includeDependencies: true`. +Pass file paths and positive, 1-based line and UTF-16 column coordinates. A column counts UTF-16 code units. Navigation and hover use column 1 when omitted. Caller and callee queries (Go and TypeScript) use the enclosing named function or method when the column is omitted. Ruby caller/callee queries use the enclosing method for a line-only target; a column can target a declaration or call site. Their required `depth` is a nonnegative integer; depth 1 returns direct neighbors. Set `includeDependencies: true` to include available dependency or standard-library source outside the project; the default is `false`. Ruby dependency callers are scanned only when `includeDependencies` is `true`. -An operation a server does not support rejects with a capability error. Caller and callee results are a static call graph. They can omit dynamic calls and do not load every possible package consumer. Use them as the server's known call relationships, not as a complete runtime graph. +An operation a server does not support rejects with a capability error; for example, gopls v0.24.0 does not support declaration lookup. Caller and callee results are static call graphs, not complete runtime graphs; they can omit dynamic calls and do not load every possible package consumer. Ruby results are best-effort: ambiguous calls retain every candidate definition, while dynamic or metaprogrammed calls may be missing. Ruby LSP may not resolve assignment setters such as `obj.value = x`; unresolved setter edges are omitted rather than reported as getter calls. An empty result does not prove a method is unused. For example, call graph helpers inside an async custom check: