From 048e5149ecd699f9220f4cad35a2c0cc854d51af Mon Sep 17 00:00:00 2001 From: Alexandre Dubois Date: Thu, 5 Nov 2026 22:01:15 +0100 Subject: [PATCH] Add ruby lsp --- .github/workflows/ci.yml | 5 + skills/coderuler-new-rule/reference.md | 4 +- src/adapter/filesystem/rule-test-worker.ts | 10 +- src/adapter/filesystem/rule-tests.ts | 63 ++- src/adapter/lsp/backend.ts | 86 +++- src/app/execute.ts | 22 +- src/app/ports.ts | 3 +- src/app/rule.ts | 1 + src/infra/runtime.ts | 8 +- tests/code-scopes.test.ts | 7 +- tests/contracts.test.ts | 26 +- tests/engine.test.ts | 135 ++++++ tests/fixtures/ruby-lsp/expected.json | 56 +++ .../project-dependency/lib/support_widget.rb | 1 + .../project-dependency/support-widget.gemspec | 7 + .../.coderuler/ruby-definition.rule.ts | 43 ++ tests/fixtures/ruby-lsp/project/Gemfile | 4 + tests/fixtures/ruby-lsp/project/Gemfile.lock | 42 ++ .../fixtures/ruby-lsp/project/lib/unicode.rb | 2 + tests/fixtures/ruby-lsp/project/lib/usage.rb | 5 + tests/fixtures/ruby-lsp/project/lib/widget.rb | 6 + tests/lsp-cli-smoke.test.ts | 46 +- tests/lsp-process.test.ts | 415 +++++++++++++++++- tests/lsp.test.ts | 1 + tests/ruby-lsp-support.ts | 36 ++ tests/ruby-lsp.test.ts | 231 ++++++++++ tests/rule-resolution.test.ts | 24 +- tests/rule-test-pool.test.ts | 237 +++++++--- tests/structured-scopes.test.ts | 7 +- website/src/content/docs/writing-rules.md | 6 +- 30 files changed, 1412 insertions(+), 127 deletions(-) create mode 100644 tests/fixtures/ruby-lsp/expected.json create mode 100644 tests/fixtures/ruby-lsp/project-dependency/lib/support_widget.rb create mode 100644 tests/fixtures/ruby-lsp/project-dependency/support-widget.gemspec create mode 100644 tests/fixtures/ruby-lsp/project/.coderuler/ruby-definition.rule.ts create mode 100644 tests/fixtures/ruby-lsp/project/Gemfile create mode 100644 tests/fixtures/ruby-lsp/project/Gemfile.lock create mode 100644 tests/fixtures/ruby-lsp/project/lib/unicode.rb create mode 100644 tests/fixtures/ruby-lsp/project/lib/usage.rb create mode 100644 tests/fixtures/ruby-lsp/project/lib/widget.rb create mode 100644 tests/ruby-lsp-support.ts create mode 100644 tests/ruby-lsp.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d89a2c4..470a635 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,6 +25,11 @@ jobs: with: go-version: '1.27.3' cache: false + - uses: ruby/setup-ruby@v1 + with: + ruby-version: '4.0.7' + - run: gem install ruby-lsp --version 0.26.11 --no-document + - run: bundle install --gemfile tests/fixtures/ruby-lsp/project/Gemfile - run: GOBIN="$RUNNER_TEMP/gopls-bin" go install golang.org/x/tools/gopls@v0.24.0 - run: echo "$RUNNER_TEMP/gopls-bin" >> "$GITHUB_PATH" - run: npm ci diff --git a/skills/coderuler-new-rule/reference.md b/skills/coderuler-new-rule/reference.md index dc84a65..76484cb 100644 --- a/skills/coderuler-new-rule/reference.md +++ b/skills/coderuler-new-rule/reference.md @@ -355,9 +355,9 @@ 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. LSP use is optional; install Go and `gopls` only if a rule calls these helpers. Declaration lookup is part of the cross-backend API, but gopls v0.24.0 does not support it, so that query rejects. +`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. LSP use is optional; install Go and `gopls` or Shopify Ruby LSP (`ruby-lsp`) on `PATH` only if a rule uses that language's helpers. CodeRuler does not install either server. 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 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`. +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 only) 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`. 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. diff --git a/src/adapter/filesystem/rule-test-worker.ts b/src/adapter/filesystem/rule-test-worker.ts index 13b4333..ef2b2b4 100644 --- a/src/adapter/filesystem/rule-test-worker.ts +++ b/src/adapter/filesystem/rule-test-worker.ts @@ -13,6 +13,7 @@ import { createRuleLoader } from './rules.js'; export interface RuleTestWorkerRequest { readonly id: number; readonly lspPort: MessagePort; + readonly rubyLspPort: MessagePort; readonly root: string; readonly ruleFile: string; readonly files: RuleTestCase['files']; @@ -38,7 +39,7 @@ function fixturePath(root: string, filepath: string): string { } async function runCase(request: RuleTestWorkerRequest): Promise { - const { lspPort, root, ruleFile, files, ai, dependencies, includeDisabled } = request; + const { lspPort, rubyLspPort, root, ruleFile, files, ai, dependencies, includeDisabled } = request; try { for (const [filepath, source] of Object.entries(files)) { const target = fixturePath(root, filepath); @@ -58,6 +59,7 @@ async function runCase(request: RuleTestWorkerRequest): Promise createRuleTestLspBackend(lspPort, signal), + createRubyLspBackend: (_project, signal) => createRuleTestLspBackend(rubyLspPort, signal), }); const report = reports[0]; if (reports.length !== 1 || report === undefined) throw new Error('Expected exactly one rule report'); @@ -70,7 +72,11 @@ async function runCase(request: RuleTestWorkerRequest): Promise }; + host: { close(): Promise } | undefined; +} + +interface ActiveRuleTestLsp { + readonly go: RuleTestLspPair; + readonly ruby: RuleTestLspPair; transferred: boolean; closePromise?: Promise; } @@ -121,10 +126,19 @@ class RuleTestWorkerSession { if (active.closePromise !== undefined) return active.closePromise; active.closePromise = (async () => { try { - await active.host.close(); + const hosts = [active.go.host, active.ruby.host].filter( + (host): host is { close(): Promise } => host !== undefined, + ); + const results = await Promise.allSettled(hosts.map(async host => host.close())); + const rejected = results.find(result => result.status === 'rejected'); + if (rejected?.status === 'rejected') throw rejected.reason; } finally { - active.channel.port1.close(); - if (!active.transferred) active.channel.port2.close(); + active.go.channel.port1.close(); + active.ruby.channel.port1.close(); + if (!active.transferred) { + active.go.channel.port2.close(); + active.ruby.channel.port2.close(); + } if (this.activeLsp === active) this.activeLsp = undefined; } })(); @@ -132,10 +146,11 @@ class RuleTestWorkerSession { } async run( - request: Omit, + request: Omit, deadline: number, onFailure: (error: Error) => void, createBackend?: LspBackendFactory, + createRubyLspBackend?: LspBackendFactory, ): Promise { this.onFailure = onFailure; this.assertHealthy(); @@ -163,24 +178,32 @@ class RuleTestWorkerSession { try { expire(); if (performance.now() < deadline) { - const channel = new MessageChannel(); + const channels: MessageChannel[] = []; try { - const host = createRuleTestLspHost( - channel.port1, - createProject(request.root, { excludeFiles: [request.ruleFile] }), - createBackend, - ); - activeLsp = { channel, host, transferred: false }; + const goChannel = new MessageChannel(); + channels.push(goChannel); + const rubyChannel = new MessageChannel(); + channels.push(rubyChannel); + activeLsp = { + go: { channel: goChannel, host: undefined }, + ruby: { channel: rubyChannel, host: undefined }, + transferred: false, + }; this.activeLsp = activeLsp; + const project = createProject(request.root, { excludeFiles: [request.ruleFile] }); + activeLsp.go.host = createRuleTestLspHost(goChannel.port1, project, createBackend); + activeLsp.ruby.host = createRuleTestLspHost(rubyChannel.port1, project, createRubyLspBackend); this.worker.postMessage( - { ...request, id, lspPort: channel.port2 } satisfies RuleTestWorkerRequest, - [channel.port2], + { ...request, id, lspPort: goChannel.port2, rubyLspPort: rubyChannel.port2 } satisfies RuleTestWorkerRequest, + [goChannel.port2, rubyChannel.port2], ); activeLsp.transferred = true; } catch (error) { if (activeLsp === undefined) { - channel.port1.close(); - channel.port2.close(); + for (const channel of channels) { + channel.port1.close(); + channel.port2.close(); + } } throw error; } @@ -249,6 +272,7 @@ async function runCase( onFailure: (error: unknown) => void, includeDisabled: boolean, createLspBackend?: LspBackendFactory, + createRubyLspBackend?: LspBackendFactory, ): Promise { const started = performance.now(); const deadline = started + timeoutMs; @@ -275,7 +299,7 @@ async function runCase( root, ruleFile: entry.ruleFile, files: example.files, ai, ...(includeDisabled ? { includeDisabled: true } : {}), ...(dependencies === undefined ? {} : { dependencies }), - }, deadline, failCase, createLspBackend); + }, deadline, failCase, createLspBackend, createRubyLspBackend); if (typeof outcome === 'string') actual = outcome; else { actual = outcome.actual; @@ -315,6 +339,7 @@ export async function runRuleTests( includeAI = false, includeDisabled = false, createLspBackend?: LspBackendFactory, + createRubyLspBackend?: LspBackendFactory, ): Promise { if (!Number.isSafeInteger(concurrency) || concurrency < 1) { throw new RangeError(`--concurrency must be a positive safe integer, received ${String(concurrency)}`); @@ -375,7 +400,7 @@ export async function runRuleTests( const group = groups[groupIndex]!; const example = validateCase(group.values[caseIndex], `${group.label}[${caseIndex}]`); completed[index] = await runCase( - rule, example, group.ai, timeoutMs, session, fail, includeDisabled, createLspBackend, + rule, example, group.ai, timeoutMs, session, fail, includeDisabled, createLspBackend, createRubyLspBackend, ); if (failureObserved) return; while (completed[nextResult] !== undefined) { diff --git a/src/adapter/lsp/backend.ts b/src/adapter/lsp/backend.ts index b917571..d81a7e0 100644 --- a/src/adapter/lsp/backend.ts +++ b/src/adapter/lsp/backend.ts @@ -17,11 +17,15 @@ import { ImplementationRequest, InitializeRequest, InitializedNotification, + MessageType, ReferencesRequest, + ShowMessageNotification, ShowMessageRequest, ShutdownRequest, SymbolKind, TypeDefinitionRequest, + WorkDoneProgress, + WorkDoneProgressCreateRequest, WorkspaceSymbolRequest, type ProtocolRequestType, type RequestParam, @@ -56,6 +60,7 @@ export interface LspServerOptions { readonly command: string; readonly args?: readonly string[]; readonly languageId: string; + readonly initializationProgressToken?: string | number; readonly env?: NodeJS.ProcessEnv; readonly requestTimeoutMs?: number; } @@ -254,10 +259,12 @@ export async function createLspBackend( const root = resolve(project.root); const rootUri = pathToFileURL(root).href; const workspaceFolder: WorkspaceFolder = { uri: rootUri, name: basename(root) }; + const usesProcessGroup = settings.languageId === 'ruby' && process.platform !== 'win32'; const child = spawn(settings.command, [...(settings.args ?? [])], { cwd: root, env: settings.env, shell: false, + detached: usesProcessGroup, stdio: ['pipe', 'pipe', 'pipe'], }); @@ -270,12 +277,16 @@ 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>(); const astPromises = new Map>(); const { promise: processClosedPromise, resolve: resolveProcessClosed } = Promise.withResolvers(); + const progressToken = settings.initializationProgressToken; + const readiness = progressToken === undefined ? undefined : Promise.withResolvers(); + let readinessPending = readiness !== undefined; const stderrText = (): string => stderr.toString('utf8').trim(); const withStderr = (error: unknown): Error => { @@ -284,6 +295,19 @@ export async function createLspBackend( return createError(detail === '' ? message : `${message}\nLSP stderr (latest ${MAX_STDERR_BYTES} bytes):\n${detail}`, error); }; const closedError = (): Error => terminalError ?? new Error('LSP session is closed'); + const signalChild = (signalName: NodeJS.Signals): void => { + if (usesProcessGroup) { + const pid = child.pid; + if (pid === undefined) return; + try { + process.kill(-pid, signalName); + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code !== 'ESRCH') throw error; + } + return; + } + child.kill(signalName); + }; const cleanup = (graceful: boolean): Promise => { if (releasePromise !== undefined) return releasePromise; @@ -299,7 +323,7 @@ export async function createLspBackend( })(); void shutdown.catch(() => undefined); } else if (!processClosed) { - child.kill('SIGTERM'); + signalChild('SIGTERM'); } if (!processClosed) { @@ -308,7 +332,7 @@ export async function createLspBackend( const closed = processClosedPromise.then(() => 'closed' as const); const result = await Promise.race([closed, expired]); clearTimeout(releaseTimer); - if (result === 'timeout' && !processClosed) child.kill('SIGKILL'); + if (result === 'timeout' && !processClosed) signalChild('SIGKILL'); if (!processClosed) await processClosedPromise; } @@ -422,6 +446,21 @@ export async function createLspBackend( failureReason: 'Read-only LSP session', })); connection.onRequest(ShowMessageRequest.type, () => null); + if (readiness !== undefined && progressToken !== undefined) { + connection.onProgress(WorkDoneProgress.type, progressToken, progress => { + if (progress.kind !== 'end') return; + readinessPending = false; + readiness.resolve(); + }); + connection.onRequest(WorkDoneProgressCreateRequest.type, () => undefined); + connection.onNotification(ShowMessageNotification.type, ({ type, message }) => { + if ( + readinessPending && + type === MessageType.Error && + message.startsWith('Error while indexing (') + ) fail(new Error(message)); + }); + } connection.listen(); const initializeResult = await bounded('initialize', () => connection!.sendRequest(InitializeRequest.type, { @@ -430,12 +469,19 @@ export async function createLspBackend( capabilities: { general: { positionEncodings: ['utf-16'] }, textDocument: { documentSymbol: { hierarchicalDocumentSymbolSupport: true } }, + ...(readiness === undefined ? {} : { window: { workDoneProgress: true } }), }, 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); } catch (error) { fail(error); await cleanup(false).catch(() => undefined); @@ -509,6 +555,25 @@ 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 filterLocations = (result: unknown, includeDependencies: boolean): readonly Location[] => normalizeLocations(result).filter(location => isAllowedUri(location.uri, includeDependencies)); @@ -722,9 +787,10 @@ export async function createLspBackend( target: LspTarget, includeDependencies: boolean, ): Promise => { + assertCapability('textDocument/prepareCallHierarchy', 'callHierarchyProvider'); const document = await documentForPath(target.filepath, includeDependencies); let position: Position; - if (target.column !== undefined) position = document.index.position(target.line, target.column); + if (target.column !== undefined) position = positionForTarget(document, target.line, target.column); else { if (!Number.isSafeInteger(target.line) || target.line < 1 || target.line > document.index.lineCount) { throw new Error(`LSP line ${target.line} is outside the document`); @@ -822,7 +888,7 @@ export async function createLspBackend( ): Promise => { const includeDependencies = options?.includeDependencies === true; const document = await documentForPath(target.filepath, includeDependencies); - const position = document.index.position(target.line, target.column ?? 1); + const position = positionForTarget(document, target.line, target.column ?? 1); const result = await request(type, method, { textDocument: { uri: document.uri }, position, @@ -842,7 +908,7 @@ export async function createLspBackend( references: async (target: LspTarget, options?: LspReferenceOptions) => { const includeDependencies = options?.includeDependencies === true; const document = await documentForPath(target.filepath, includeDependencies); - const position = document.index.position(target.line, target.column ?? 1); + const position = positionForTarget(document, target.line, target.column ?? 1); const result = await request(ReferencesRequest.type, 'textDocument/references', { textDocument: { uri: document.uri }, position, @@ -853,7 +919,7 @@ export async function createLspBackend( hover: async (target: LspTarget, options?: LspOptions): Promise => { const includeDependencies = options?.includeDependencies === true; const document = await documentForPath(target.filepath, includeDependencies); - const position = document.index.position(target.line, target.column ?? 1); + const position = positionForTarget(document, target.line, target.column ?? 1); const result = await request(HoverRequest.type, 'textDocument/hover', { textDocument: { uri: document.uri }, position, @@ -884,3 +950,11 @@ export async function createLspBackend( return { ...tools, close }; } +export function createRubyLspBackend(project: Project, signal: AbortSignal): Promise { + return createLspBackend(project, signal, { + command: 'ruby-lsp', + args: [], + languageId: 'ruby', + initializationProgressToken: 'indexing-progress', + }); +} diff --git a/src/app/execute.ts b/src/app/execute.ts index a241faf..b42291d 100644 --- a/src/app/execute.ts +++ b/src/app/execute.ts @@ -84,8 +84,16 @@ export async function executeRules( rules: readonly LoadedRule[], context: RunContext, ): Promise { - const lsp = createRunLsp(context.project, context.createLspBackend); - const abortLsp = (): void => { void lsp.close().catch(() => undefined); }; + const goLsp = createRunLsp(context.project, context.createLspBackend); + const rubyLsp = createRunLsp(context.project, context.createRubyLspBackend); + const closeLspSessions = async (): Promise => { + const results = await Promise.allSettled([goLsp.close(), rubyLsp.close()]); + const goResult = results[0]!; + if (goResult.status === 'rejected') throw goResult.reason; + const rubyResult = results[1]!; + if (rubyResult.status === 'rejected') throw rubyResult.reason; + }; + const abortLsp = (): void => { void closeLspSessions().catch(() => undefined); }; const startedWork = new Set>(); const scheduleWork = (work: Promise): Promise => { startedWork.add(work); @@ -100,7 +108,7 @@ export async function executeRules( }; let failed = false; try { - const reports = await executeRun(rules, context, lsp.tools, scheduleWork, abortLsp); + const reports = await executeRun(rules, context, goLsp.tools, rubyLsp.tools, scheduleWork, abortLsp); await drainStartedWork(); return reports; } catch (error) { @@ -110,7 +118,7 @@ export async function executeRules( throw error; } finally { try { - await lsp.close(); + await closeLspSessions(); } catch (error) { if (!failed) throw error; } @@ -120,7 +128,8 @@ export async function executeRules( async function executeRun( rules: readonly LoadedRule[], context: RunContext, - lsp: LspTools, + goLsp: LspTools, + rubyLsp: LspTools, scheduleWork: (work: Promise) => Promise, abortLsp: () => void, ): Promise { @@ -149,7 +158,8 @@ async function executeRun( const projectContextFor = (name: string, evaluationId?: number): ProjectContext => ({ project: context.project, - goLsp: lsp, + goLsp, + rubyLsp, ...(changedFiles === undefined ? {} : { changedFiles }), ...(changedLines === undefined ? {} : { changedLines }), trace: traceFor(name, evaluationId), diff --git a/src/app/ports.ts b/src/app/ports.ts index cadc4c5..a4d6294 100644 --- a/src/app/ports.ts +++ b/src/app/ports.ts @@ -96,8 +96,9 @@ export interface DecisionRuntime { export interface RunContext { readonly project: Project; - readonly createLspBackend?: LspBackendFactory; readonly isTarget: (filepath: string) => boolean; + readonly createLspBackend?: LspBackendFactory; + readonly createRubyLspBackend?: LspBackendFactory; readonly changedFiles?: ReadonlySet; readonly changedLines?: ReadonlyMap; readonly concurrency: number; diff --git a/src/app/rule.ts b/src/app/rule.ts index 2a0f6c9..2e37178 100644 --- a/src/app/rule.ts +++ b/src/app/rule.ts @@ -175,6 +175,7 @@ export type Evaluator = ( export interface ProjectContext { readonly project: Project; readonly goLsp: LspTools; + readonly rubyLsp: LspTools; readonly changedFiles?: ReadonlySet; readonly changedLines?: ReadonlyMap; trace(message: string, details?: JsonValue): void; diff --git a/src/infra/runtime.ts b/src/infra/runtime.ts index 50debbf..9ee1952 100644 --- a/src/infra/runtime.ts +++ b/src/infra/runtime.ts @@ -2,7 +2,7 @@ import { stat } from 'node:fs/promises'; import { homedir } from 'node:os'; import { join, resolve } from 'node:path'; -import { createLspBackend } from '../adapter/lsp/backend.js'; +import { createLspBackend, createRubyLspBackend } from '../adapter/lsp/backend.js'; import { runProgram } from '../adapter/cli/program.js'; import { terminalCapabilities } from '../adapter/cli/progress.js'; import type { @@ -87,6 +87,7 @@ export function createServices( execution.executeRules(rules, { ...runContext, createLspBackend: runContext.createLspBackend ?? createLspBackend, + createRubyLspBackend: runContext.createRubyLspBackend ?? createRubyLspBackend, }), decisionResolver: (thresholdOverride?: number, pricingOverrides?: Partial) => settings.createDecisionResolver(env, thresholdOverride, pricingOverrides), @@ -123,6 +124,7 @@ export function createServices( execution.executeRules(rules, { ...runContext, createLspBackend: runContext.createLspBackend ?? createLspBackend, + createRubyLspBackend: runContext.createRubyLspBackend ?? createRubyLspBackend, }), decisionResolver: (thresholdOverride?: number, pricingOverrides?: Partial) => settings.createDecisionResolver(env, thresholdOverride, pricingOverrides), @@ -134,7 +136,9 @@ export function createServices( return { ...ruleServices, testRules: (rules, onResult, timeoutMs, concurrency, includeAI) => - runRuleTests(rules, onResult, timeoutMs, concurrency, includeAI, false, createLspBackend), + runRuleTests( + rules, onResult, timeoutMs, concurrency, includeAI, false, createLspBackend, createRubyLspBackend, + ), }; }, async update(_context: CliContext) { diff --git a/tests/code-scopes.test.ts b/tests/code-scopes.test.ts index 77ac162..11f361e 100644 --- a/tests/code-scopes.test.ts +++ b/tests/code-scopes.test.ts @@ -33,7 +33,12 @@ function astGrepWith(options: Record): ScopeSelector } const project = createProject(ROOT); -const projectContext: ProjectContext = { project, goLsp: createRunLsp(project).tools, trace: () => {} }; +const projectContext: ProjectContext = { + project, + goLsp: createRunLsp(project).tools, + rubyLsp: createRunLsp(project).tools, + trace: () => {}, +}; async function select( selector: ScopeSelector, filepath: string, diff --git a/tests/contracts.test.ts b/tests/contracts.test.ts index 475cd65..685dce2 100644 --- a/tests/contracts.test.ts +++ b/tests/contracts.test.ts @@ -96,6 +96,7 @@ interface TestContextOptions { class TestContext implements CheckContext { readonly project: Project; readonly goLsp: ProjectContext['goLsp']; + readonly rubyLsp: ProjectContext['rubyLsp']; readonly ruleName: string; readonly traces: CapturedTrace[] = []; readonly selections: unknown[] = []; @@ -107,6 +108,7 @@ class TestContext implements CheckContext { constructor(options: TestContextOptions = {}) { this.project = options.project ?? new MemoryProject(); this.goLsp = createRunLsp(this.project).tools; + this.rubyLsp = createRunLsp(this.project).tools; this.ruleName = options.ruleName ?? 'contract-rule'; this.#subject = options.subject; this.#runtime = options.runtime; @@ -138,6 +140,7 @@ class TestContext implements CheckContext { { project: this.project, goLsp: this.goLsp, + rubyLsp: this.rubyLsp, ruleName: this.ruleName, trace: (message, details) => this.trace(message, details), }, @@ -176,7 +179,13 @@ function requestAt(backend: RecordingBackend, index = 0): DecisionRequest { function projectContext(ruleName = 'contract-rule'): ProjectContext & { readonly ruleName: string } { const project = new MemoryProject(); - return { project, goLsp: createRunLsp(project).tools, ruleName, trace: () => {} }; + return { + project, + goLsp: createRunLsp(project).tools, + rubyLsp: createRunLsp(project).tools, + ruleName, + trace: () => {}, + }; } function scopeSubject(subject: Omit, 'kind'>): ScopeSubject { @@ -327,7 +336,12 @@ test('Rule exposes its documented defaults', async () => { assert.equal(rule.group, 'scope'); const project = new MemoryProject(); - const context: ProjectContext = { project, goLsp: createRunLsp(project).tools, trace: () => {} }; + const context: ProjectContext = { + project, + goLsp: createRunLsp(project).tools, + rubyLsp: createRunLsp(project).tools, + trace: () => {}, + }; assert.equal(await rule.when(context), true); const subject = scopeSubject({ filepath: 'a.txt', id: '$', source: 'text' }); assert.equal(await rule.filter(subject, context), true); @@ -1051,7 +1065,13 @@ test('an input callback replaces the projected state', async () => { return { custom: context.project.root }; }, }, - { project, goLsp: createRunLsp(project).tools, ruleName: 'r', trace: () => {} }, + { + project, + goLsp: createRunLsp(project).tools, + rubyLsp: createRunLsp(project).tools, + ruleName: 'r', + trace: () => {}, + }, runtimeFor(backend), ); diff --git a/tests/engine.test.ts b/tests/engine.test.ts index 254c736..ef822cc 100644 --- a/tests/engine.test.ts +++ b/tests/engine.test.ts @@ -2906,6 +2906,141 @@ test('nested selection failure is recorded before sibling LSP work is aborted', assert.deepEqual(eventsOfType(harness.events, 'error').map(event => event.message)[0], failure.message); }); +test('Ruby-only work starts only Ruby LSP, and one run can start both language backends', async () => { + const harness = new Harness(new MemoryProject('/repo', { 'a.txt': 'A' })); + let goStarts = 0; + let rubyStarts = 0; + let goCloses = 0; + let rubyCloses = 0; + const context: RunContext = { + ...harness.context, + createLspBackend: async () => { + goStarts += 1; + return lifecycleBackend(async () => { goCloses += 1; }); + }, + createRubyLspBackend: async () => { + rubyStarts += 1; + return lifecycleBackend(async () => { rubyCloses += 1; }); + }, + }; + + const rubyOnly = new TestRule({ + when: async projectContext => { + await projectContext.rubyLsp.hover({ filepath: 'a.rb', line: 1 }); + return true; + }, + }); + await executeRules([loaded('ruby-only', rubyOnly)], context); + assert.equal(goStarts, 0, 'Ruby-only work must not start the Go server'); + assert.equal(rubyStarts, 1); + assert.equal(goCloses, 0); + assert.equal(rubyCloses, 1); + + const both = new TestRule({ + when: async projectContext => { + await Promise.all([ + projectContext.goLsp.hover({ filepath: 'a.go', line: 1 }), + projectContext.rubyLsp.hover({ filepath: 'a.rb', line: 1 }), + ]); + return true; + }, + }); + await executeRules([loaded('both-languages', both)], context); + assert.equal(goStarts, 1); + assert.equal(rubyStarts, 2); + assert.equal(goCloses, 1); + assert.equal(rubyCloses, 2); +}); + +test('a rule failure aborts pending Ruby work before the run drains it', { timeout: 5000 }, async () => { + const failure = new Error('Ruby-backed check failed'); + const requestStarted = Promise.withResolvers(); + const requestSettled = Promise.withResolvers(); + let aborted = false; + let closes = 0; + const rule = new TestRule({ + files: ['a.txt'], + scope: changedLineScope, + check: async (subject, context) => { + if (subject.kind !== 'scope') return pass(); + if (subject.source === 'fail') { + await context.rubyLsp.hover({ filepath: 'a.rb', line: 2 }); + throw failure; + } + await context.rubyLsp.hover({ filepath: 'a.rb', line: 1 }); + return pass(); + }, + }); + const harness = new Harness(new MemoryProject('/repo', { 'a.txt': 'wait\nfail' }), { concurrency: 2 }); + const context: RunContext = { + ...harness.context, + createRubyLspBackend: async (_project, signal) => ({ + ...lifecycleBackend(async () => { closes += 1; }), + hover: async target => { + if (target.line === 1) { + const pending = Promise.withResolvers(); + signal.addEventListener('abort', () => { + aborted = true; + pending.reject(new Error('Ruby LSP request aborted')); + }, { once: true }); + requestStarted.resolve(); + return pending.promise.finally(() => requestSettled.resolve()); + } + await requestStarted.promise; + return null; + }, + }), + }; + + await assert.rejects( + () => executeRules([loaded('ruby-failure-before-drain', rule)], context), + error => error === failure, + ); + await requestSettled.promise; + assert.equal(aborted, true, 'pending Ruby work must observe run cancellation'); + assert.equal(closes, 1); +}); + +test('run cleanup closes both language backends and preserves primary or standalone failures', async () => { + const project = new MemoryProject('/repo', { 'a.txt': 'A' }); + const ruleFailure = new Error('check failed'); + const closeFailure = new Error('Go backend close failed'); + for (const failsInRule of [true, false]) { + const closed: string[] = []; + const rule = new TestRule({ + files: ['a.txt'], + when: async context => { + await Promise.all([ + context.goLsp.hover({ filepath: 'a.go', line: 1 }), + context.rubyLsp.hover({ filepath: 'a.rb', line: 1 }), + ]); + return true; + }, + check: async () => { + if (failsInRule) throw ruleFailure; + return pass(); + }, + }); + const harness = new Harness(project); + const context: RunContext = { + ...harness.context, + createLspBackend: async () => lifecycleBackend(async () => { + closed.push('go'); + throw closeFailure; + }), + createRubyLspBackend: async () => lifecycleBackend(async () => { + closed.push('ruby'); + }), + }; + + await assert.rejects( + () => executeRules([loaded('close-both-languages', rule)], context), + error => error === (failsInRule ? ruleFailure : closeFailure), + ); + assert.deepEqual([...closed].sort(), ['go', 'ruby'], 'Ruby must still close when Go cleanup fails'); + } +}); + test('run cleanup preserves rule errors and reports standalone backend close failures', async () => { const project = new MemoryProject('/repo', { 'a.txt': 'A' }); const ruleFailure = new Error('check failed'); diff --git a/tests/fixtures/ruby-lsp/expected.json b/tests/fixtures/ruby-lsp/expected.json new file mode 100644 index 0000000..c894c2a --- /dev/null +++ b/tests/fixtures/ruby-lsp/expected.json @@ -0,0 +1,56 @@ +{ + "widgetDefinitions": [ + { + "path": "lib/widget.rb", + "line": 2 + } + ], + "unicodeWidgetDefinitions": [ + { + "path": "lib/widget.rb", + "line": 2 + } + ], + "widgetReferences": [ + { + "path": "lib/unicode.rb", + "line": 2 + }, + { + "path": "lib/usage.rb", + "line": 4 + }, + { + "path": "lib/widget.rb", + "line": 2 + } + ], + "widgetDocumentSymbols": [ + { + "name": "Widget", + "kind": 5, + "path": "lib/widget.rb", + "line": 2 + }, + { + "name": "self.build", + "kind": 12, + "path": "lib/widget.rb", + "line": 3 + } + ], + "widgetWorkspaceSymbols": [ + { + "name": "Widget", + "kind": 5, + "path": "lib/widget.rb", + "line": 2 + } + ], + "supportWidgetDefinitions": [ + { + "path": "/lib/support_widget.rb", + "line": 1 + } + ] +} diff --git a/tests/fixtures/ruby-lsp/project-dependency/lib/support_widget.rb b/tests/fixtures/ruby-lsp/project-dependency/lib/support_widget.rb new file mode 100644 index 0000000..5e980e4 --- /dev/null +++ b/tests/fixtures/ruby-lsp/project-dependency/lib/support_widget.rb @@ -0,0 +1 @@ +class SupportWidget; end diff --git a/tests/fixtures/ruby-lsp/project-dependency/support-widget.gemspec b/tests/fixtures/ruby-lsp/project-dependency/support-widget.gemspec new file mode 100644 index 0000000..d642b2b --- /dev/null +++ b/tests/fixtures/ruby-lsp/project-dependency/support-widget.gemspec @@ -0,0 +1,7 @@ +Gem::Specification.new do |spec| + spec.name = "support-widget" + spec.version = "0.1.0" + spec.authors = ["CodeRuler"] + spec.summary = "Ruby LSP fixture" + spec.files = ["lib/support_widget.rb"] +end diff --git a/tests/fixtures/ruby-lsp/project/.coderuler/ruby-definition.rule.ts b/tests/fixtures/ruby-lsp/project/.coderuler/ruby-definition.rule.ts new file mode 100644 index 0000000..21a98a8 --- /dev/null +++ b/tests/fixtures/ruby-lsp/project/.coderuler/ruby-definition.rule.ts @@ -0,0 +1,43 @@ +import { Rule, pass, test, violation, type ProjectContext, type Subject } from '@alex-dbs/coderuler'; + +const widget = `# A reusable widget. +class Widget + def self.build + new + end +end +`; +const usage = `require_relative "widget" +require "support_widget" + +Widget.build +SupportWidget.new +`; + +export default class RubyDefinition extends Rule { + files = ['lib/usage.rb']; + + check = async (subject: Subject, context: ProjectContext) => { + if (subject.kind === 'group') return pass(); + + const definitions = await context.rubyLsp.definitions({ + filepath: subject.filepath, + line: 4, + column: 1, + }); + return definitions.some(definition => definition.uri.endsWith('/lib/widget.rb')) + ? pass() + : violation('Widget must resolve to lib/widget.rb'); + }; +} + +export const tests = [ + test('Widget resolves to its definition').withFiles({ + 'lib/widget.rb': widget, + 'lib/usage.rb': usage, + }).passes(), + test('an unknown widget does not resolve').withFiles({ + 'lib/widget.rb': widget, + 'lib/usage.rb': usage.replace('Widget.build', 'UnknownWidget.build'), + }).violates(), +] as const; diff --git a/tests/fixtures/ruby-lsp/project/Gemfile b/tests/fixtures/ruby-lsp/project/Gemfile new file mode 100644 index 0000000..08e4c32 --- /dev/null +++ b/tests/fixtures/ruby-lsp/project/Gemfile @@ -0,0 +1,4 @@ +source "https://rubygems.org" + +gem "ruby-lsp", "= 0.26.11" +gem "support-widget", path: "../project-dependency" diff --git a/tests/fixtures/ruby-lsp/project/Gemfile.lock b/tests/fixtures/ruby-lsp/project/Gemfile.lock new file mode 100644 index 0000000..50672e3 --- /dev/null +++ b/tests/fixtures/ruby-lsp/project/Gemfile.lock @@ -0,0 +1,42 @@ +PATH + remote: ../project-dependency + specs: + support-widget (0.1.0) + +GEM + remote: https://rubygems.org/ + specs: + language_server-protocol (3.17.0.6) + logger (1.7.0) + prism (1.9.0) + rbs (4.2.0) + logger + prism (>= 1.6.0) + tsort + ruby-lsp (0.26.11) + language_server-protocol (~> 3.17.0) + prism (>= 1.2, < 2.0) + rbs (>= 3, < 5) + tsort (0.2.0) + +PLATFORMS + arm64-darwin + arm64-darwin-25 + ruby + x86_64-linux + +DEPENDENCIES + ruby-lsp (= 0.26.11) + support-widget! + +CHECKSUMS + language_server-protocol (3.17.0.6) sha256=5ef2c0c138f8267e1bc631d3328347d354f96724b0af22f2c79516120443b7f0 + logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 + prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 + rbs (4.2.0) sha256=51f7b886dcc05bc09e10b901daa6a81829f6adc03101d6ca9ea4aac6103e0674 + ruby-lsp (0.26.11) sha256=4cc8f1587237ff8c9031680b5491df901a82559eeaa1ae96863ffe700b32583d + support-widget (0.1.0) + tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f + +BUNDLED WITH + 4.0.20 diff --git a/tests/fixtures/ruby-lsp/project/lib/unicode.rb b/tests/fixtures/ruby-lsp/project/lib/unicode.rb new file mode 100644 index 0000000..cd84992 --- /dev/null +++ b/tests/fixtures/ruby-lsp/project/lib/unicode.rb @@ -0,0 +1,2 @@ +require_relative "widget" +"𝄞"; Widget.build diff --git a/tests/fixtures/ruby-lsp/project/lib/usage.rb b/tests/fixtures/ruby-lsp/project/lib/usage.rb new file mode 100644 index 0000000..a2cdfec --- /dev/null +++ b/tests/fixtures/ruby-lsp/project/lib/usage.rb @@ -0,0 +1,5 @@ +require_relative "widget" +require "support_widget" + +Widget.build +SupportWidget.new diff --git a/tests/fixtures/ruby-lsp/project/lib/widget.rb b/tests/fixtures/ruby-lsp/project/lib/widget.rb new file mode 100644 index 0000000..7669b87 --- /dev/null +++ b/tests/fixtures/ruby-lsp/project/lib/widget.rb @@ -0,0 +1,6 @@ +# A reusable widget. +class Widget + def self.build + new + end +end diff --git a/tests/lsp-cli-smoke.test.ts b/tests/lsp-cli-smoke.test.ts index 6014cea..6ea8faa 100644 --- a/tests/lsp-cli-smoke.test.ts +++ b/tests/lsp-cli-smoke.test.ts @@ -1,8 +1,9 @@ import assert from 'node:assert/strict'; -import { cpSync, readdirSync } from 'node:fs'; +import { cpSync, readFileSync, readdirSync } from 'node:fs'; import { after, test } from 'node:test'; import { join } from 'node:path'; +import { createRubyLspFixture } from './ruby-lsp-support.ts'; import { createSandbox, ROOT, runCli, writeFile } from './support.ts'; const sandbox = createSandbox('coderuler-lsp-cli-'); @@ -20,6 +21,49 @@ function fixture(label: string) { return target; } +test('check uses the default Ruby LSP backend to resolve the Ruby fixture', async () => { + const target = await createRubyLspFixture(sandbox.isolated('ruby-check')); + const result = await runCli([ + 'check', '.', '--rules', '.coderuler/ruby-definition.rule.ts', '--json', '--output-all', + ], target); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.match(result.stdout, /"status":\s*"pass"/); + assert.match(result.stdout, /Widget\.build/); +}); + +test('inspect uses the default Ruby LSP backend to resolve the Ruby fixture', async () => { + const target = await createRubyLspFixture(sandbox.isolated('ruby-inspect')); + const result = await runCli([ + 'inspect', '.coderuler/ruby-definition.rule.ts', '.', '--json', + ], target); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.equal(result.stderr, ''); + assert.match(result.stdout, /"status":\s*"pass"/); + assert.match(result.stdout, /Widget\.build/); +}); + +test('check reports the Ruby fixture violation for an unresolved widget', async () => { + const target = await createRubyLspFixture(sandbox.isolated('ruby-check-violation')); + const usagePath = join(target.cwd, 'lib', 'usage.rb'); + const usage = readFileSync(usagePath, 'utf8'); + assert.ok(usage.includes('Widget.build'), usage); + writeFile(usagePath, usage.replace('Widget.build', 'UnknownWidget.build')); + const result = await runCli([ + 'check', '.', '--rules', '.coderuler/ruby-definition.rule.ts', '--json', '--output-all', + ], target); + assert.equal(result.status, 1, `${result.stdout}\n${result.stderr}`); + assert.match(result.stdout, /Widget must resolve to lib\/widget\.rb/); +}); + +test('test runs both Ruby fixture examples with their declared outcomes', async () => { + const target = await createRubyLspFixture(sandbox.isolated('ruby-test')); + const result = await runCli([ + 'test', '.coderuler/ruby-definition.rule.ts', '--timeout', '60s', + ], target); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.equal(result.stdout.match(/^.*PASS.*$/gm)?.length, 2, result.stdout); +}); + const offlineGo = { GOPROXY: 'off', GOWORK: 'off', diff --git a/tests/lsp-process.test.ts b/tests/lsp-process.test.ts index 853f877..f73c451 100644 --- a/tests/lsp-process.test.ts +++ b/tests/lsp-process.test.ts @@ -20,10 +20,10 @@ interface TestProject { readonly project: Project; } -async function makeProject(): Promise { +async function makeProject(sourceText = source, filename = 'main.go'): Promise { const root = await mkdtemp(join(tmpdir(), 'rulekeeper lsp process-')); - const sourcePath = join(root, 'main.go'); - await writeFile(sourcePath, source); + const sourcePath = join(root, filename); + await writeFile(sourcePath, sourceText); return { root, sourcePath, @@ -32,6 +32,16 @@ async function makeProject(): Promise { }; } +function rubyServerOptions(command: string, args: readonly string[], requestTimeoutMs: number) { + return { + command, + args, + languageId: 'ruby', + initializationProgressToken: 'indexing-progress', + requestTimeoutMs, + } as const; +} + async function removeProject(project: TestProject): Promise { await rm(project.root, { recursive: true, force: true }); } @@ -237,6 +247,131 @@ function protocolPeerScript( connection.listen(); `; } +type RubyProgressPeerMode = 'gated' | 'early' | 'indexing-error'; + +function rubyProgressPeerScript( + pidPath: string, + exitPath: string, + markerPath: string, + releasePath: string, + mode: RubyProgressPeerMode, + descendantPidPath?: string, +): string { + const initializeHandler = mode === 'early' + ? ` + connection.onRequest('initialize', async () => { + await connection.sendRequest('window/workDoneProgress/create', { token }); + await connection.sendNotification('$/progress', { + token, + value: { kind: 'end' }, + }); + writeMarker(${JSON.stringify(markerPath)}, 'early-end-sent'); + return { capabilities: { textDocumentSync: 1 } }; + }); + ` + : ` + connection.onRequest('initialize', () => ({ + capabilities: { textDocumentSync: 1 }, + })); + `; + const initializedHandler = mode === 'gated' + ? ` + connection.onNotification('initialized', async () => { + await connection.sendRequest('window/workDoneProgress/create', { token }); + await connection.sendNotification('$/progress', { + token, + value: { kind: 'begin', title: 'Indexing' }, + }); + await connection.sendNotification('$/progress', { + token, + value: { kind: 'report', message: 'Indexing files' }, + }); + await connection.sendNotification('$/progress', { + token: 'unrelated-token', + value: { kind: 'end' }, + }); + await connection.sendRequest('window/showMessageRequest', { + type: 3, + message: 'progress synchronization barrier', + }); + + const watcher = watch(${JSON.stringify(dirname(releasePath))}, () => { + if (!existsSync(${JSON.stringify(releasePath)})) return; + watcher.close(); + void connection.sendNotification('$/progress', { + token, + value: { kind: 'end' }, + }).then(() => writeMarker(${JSON.stringify(`${markerPath}.end-sent`)}, 'end-sent')); + }); + writeMarker(${JSON.stringify(markerPath)}, 'indexing-in-progress'); + }); + ` + : mode === 'indexing-error' + ? ` + connection.onNotification('initialized', async () => { + await connection.sendRequest('window/workDoneProgress/create', { token }); + const indexingError = connection.sendNotification('window/showMessage', { + type: 1, + message: 'Error while indexing (Ruby parser error)', + }); + const indexingEnd = connection.sendNotification('$/progress', { + token, + value: { kind: 'end' }, + }); + writeMarker(${JSON.stringify(markerPath)}, 'indexing-error-and-end-sent'); + await Promise.all([indexingError, indexingEnd]); + }); + ` + : ''; + const descendantScript = descendantPidPath === undefined + ? '' + : ` + import { writeFileSync } from 'node:fs'; + process.on('SIGTERM', () => {}); + // The referenced timer keeps the descendant alive until process-group shutdown. + setInterval(() => {}, 1_000); + writeFileSync(${JSON.stringify(`${descendantPidPath}.ready`)}, String(process.pid)); + `; + const spawnDescendant = descendantPidPath === undefined + ? '' + : ` + const descendant = spawn(process.execPath, [ + '--input-type=module', + '-e', + ${JSON.stringify(descendantScript)}, + ], { stdio: ['ignore', 'ignore', 'inherit'] }); + writeMarker(${JSON.stringify(descendantPidPath)}, String(descendant.pid)); + `; + + return ` + import { spawn } from 'node:child_process'; + import { existsSync, renameSync, watch, writeFileSync } from 'node:fs'; + const writeMarker = (path, value) => { + const temporary = path + '.' + process.pid + '.tmp'; + writeFileSync(temporary, value); + renameSync(temporary, path); + }; + const token = 'indexing-progress'; + writeMarker(${JSON.stringify(pidPath)}, String(process.pid)); + ${spawnDescendant} + process.on('exit', () => writeMarker(${JSON.stringify(exitPath)}, String(process.pid))); + process.on('SIGTERM', () => process.exit(0)); + // The peer must load the protocol implementation in its own process. + const { createProtocolConnection } = await import(${JSON.stringify(protocolNodeModuleUrl)}); + const connection = createProtocolConnection(process.stdin, process.stdout); + ${initializeHandler} + connection.onRequest('shutdown', () => null); + connection.onNotification('exit', () => process.exit(0)); + ${initializedHandler} + connection.listen(); + `; +} + +async function nextEventLoopTurn(): Promise { + const { promise, resolve } = Promise.withResolvers(); + setImmediate(resolve); + await promise; +} test('a nonexistent executable rejects during startup', { timeout: 10_000 }, async () => { const fixture = await makeProject(); @@ -323,6 +458,280 @@ test('aborting initialization rejects before timeout and kills the child', { tim } }); +test('Ruby startup waits for the matching indexing end event', { timeout: 15_000 }, async () => { + const fixture = await makeProject(); + const progressPath = join(fixture.root, 'indexing.progress'); + const releasePath = join(fixture.root, 'release-indexing'); + const exitPath = join(fixture.root, 'child.exited'); + let backend: LspBackend | undefined; + try { + let startupResolved = false; + const startup = createLspBackend( + fixture.project, + new AbortController().signal, + rubyServerOptions( + process.execPath, + nodeArgs(rubyProgressPeerScript( + fixture.pidPath, + exitPath, + progressPath, + releasePath, + 'gated', + )), + 5_000, + ), + ); + void startup.then( + resolvedBackend => { + startupResolved = true; + backend = resolvedBackend; + }, + () => { startupResolved = true; }, + ); + + await waitForMarker(progressPath, value => value === 'indexing-in-progress'); + await nextEventLoopTurn(); + assert.equal(startupResolved, false); + + await writeFile(releasePath, 'release'); + await waitForMarker(`${progressPath}.end-sent`, value => value === 'end-sent'); + backend = await startup; + await backend.close(); + backend = undefined; + await waitForMarker(exitPath, validPidMarker); + } finally { + await cleanupProject(fixture, backend); + } +}); + +test('Ruby startup retains a matching end received during initialize', { timeout: 10_000 }, async () => { + const fixture = await makeProject(); + const progressPath = join(fixture.root, 'indexing.progress'); + const releasePath = join(fixture.root, 'unused-release'); + const exitPath = join(fixture.root, 'child.exited'); + let backend: LspBackend | undefined; + try { + backend = await createLspBackend( + fixture.project, + new AbortController().signal, + rubyServerOptions( + process.execPath, + nodeArgs(rubyProgressPeerScript( + fixture.pidPath, + exitPath, + progressPath, + releasePath, + 'early', + )), + 2_000, + ), + ); + assert.equal(await waitForMarker(progressPath, value => value === 'early-end-sent'), 'early-end-sent'); + const pid = pidFromMarker(await waitForMarker(fixture.pidPath, validPidMarker)); + await backend.close(); + backend = undefined; + await waitForMarker(exitPath, validPidMarker); + await waitForProcessExit(pid); + assertProcessExited(pid); + } finally { + await cleanupProject(fixture, backend); + } +}); + +test('a Ruby indexing error still rejects when progress ends', { timeout: 10_000 }, async () => { + const fixture = await makeProject(); + const progressPath = join(fixture.root, 'indexing.progress'); + const releasePath = join(fixture.root, 'unused-release'); + const exitPath = join(fixture.root, 'child.exited'); + try { + const startup = createLspBackend( + fixture.project, + new AbortController().signal, + rubyServerOptions( + process.execPath, + nodeArgs(rubyProgressPeerScript( + fixture.pidPath, + exitPath, + progressPath, + releasePath, + 'indexing-error', + )), + 2_000, + ), + ); + const rejectedStartup = assert.rejects(startup, /Error while indexing \(/); + await waitForMarker(progressPath, value => value === 'indexing-error-and-end-sent'); + await rejectedStartup; + const pid = pidFromMarker(await waitForMarker(fixture.pidPath, validPidMarker)); + await waitForMarker(exitPath, validPidMarker); + await waitForProcessExit(pid); + assertProcessExited(pid); + } finally { + await cleanupProject(fixture); + } +}); + +test('a Ruby indexing readiness timeout reaps the child', { timeout: 15_000 }, async () => { + const fixture = await makeProject(); + const progressPath = join(fixture.root, 'indexing.progress'); + const releasePath = join(fixture.root, 'unused-release'); + const exitPath = join(fixture.root, 'child.exited'); + try { + const startup = createLspBackend( + fixture.project, + new AbortController().signal, + rubyServerOptions( + process.execPath, + nodeArgs(rubyProgressPeerScript( + fixture.pidPath, + exitPath, + progressPath, + releasePath, + 'gated', + )), + 2_000, + ), + ); + const rejectedStartup = assert.rejects(startup, /timeout|timed out/i); + await waitForMarker(progressPath, value => value === 'indexing-in-progress'); + await rejectedStartup; + const pid = pidFromMarker(await waitForMarker(fixture.pidPath, validPidMarker)); + await waitForMarker(exitPath, validPidMarker); + await waitForProcessExit(pid); + assertProcessExited(pid); + } finally { + await cleanupProject(fixture); + } +}); + +test('aborting Ruby indexing readiness reaps the child', { timeout: 10_000 }, async () => { + const fixture = await makeProject(); + const controller = new AbortController(); + const progressPath = join(fixture.root, 'indexing.progress'); + const releasePath = join(fixture.root, 'unused-release'); + const exitPath = join(fixture.root, 'child.exited'); + try { + const startup = createLspBackend( + fixture.project, + controller.signal, + rubyServerOptions( + process.execPath, + nodeArgs(rubyProgressPeerScript( + fixture.pidPath, + exitPath, + progressPath, + releasePath, + 'gated', + )), + 5_000, + ), + ); + const rejectedStartup = assert.rejects(startup, /abort/i); + await waitForMarker(progressPath, value => value === 'indexing-in-progress'); + controller.abort(); + await rejectedStartup; + const pid = pidFromMarker(await waitForMarker(fixture.pidPath, validPidMarker)); + await waitForMarker(exitPath, validPidMarker); + await waitForProcessExit(pid); + assertProcessExited(pid); + } finally { + controller.abort(); + await cleanupProject(fixture); + } +}); + +if (process.platform !== 'win32') { + test('aborting Ruby startup terminates a descendant holding stderr', { timeout: 15_000 }, async () => { + const fixture = await makeProject(); + const progressPath = join(fixture.root, 'indexing.progress'); + const releasePath = join(fixture.root, 'unused-release'); + const exitPath = join(fixture.root, 'child.exited'); + const descendantPidPath = join(fixture.root, 'descendant.pid'); + const controller = new AbortController(); + let startup: Promise | undefined; + let settledStartup: Promise | undefined; + try { + startup = createLspBackend( + fixture.project, + controller.signal, + rubyServerOptions( + process.execPath, + nodeArgs(rubyProgressPeerScript( + fixture.pidPath, + exitPath, + progressPath, + releasePath, + 'gated', + descendantPidPath, + )), + 10_000, + ), + ); + settledStartup = startup.then(() => undefined, () => undefined); + const rejectedStartup = assert.rejects(startup, /abort/i); + const pid = pidFromMarker(await waitForMarker(fixture.pidPath, validPidMarker)); + const descendantPid = pidFromMarker(await waitForMarker(descendantPidPath, validPidMarker)); + await waitForMarker(`${descendantPidPath}.ready`, value => value === String(descendantPid)); + await waitForMarker(progressPath, value => value === 'indexing-in-progress'); + assert.doesNotThrow( + () => process.kill(descendantPid, 0), + 'descendant must still be alive before abort', + ); + controller.abort(); + await rejectedStartup; + await waitForMarker(exitPath, validPidMarker); + await Promise.all([waitForProcessExit(pid), waitForProcessExit(descendantPid)]); + assertProcessExited(pid); + assertProcessExited(descendantPid); + assert.equal(await readFile(fixture.sourcePath, 'utf8'), source); + } finally { + controller.abort(); + await settledStartup; + await terminateChild(descendantPidPath); + await cleanupProject(fixture); + } + }); +} + +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'); + const exitPath = join(fixture.root, 'child.exited'); + let backend: LspBackend | undefined; + try { + backend = await createLspBackend( + fixture.project, + new AbortController().signal, + rubyServerOptions( + process.execPath, + nodeArgs(rubyProgressPeerScript( + fixture.pidPath, + exitPath, + progressPath, + releasePath, + 'early', + )), + 2_000, + ), + ); + + for (const method of ['callers', 'callees'] as const) { + for (const target of [ + { filepath: 'main.rb', line: 1, column: 1 }, + { filepath: 'main.rb', line: 1 }, + ]) { + await assert.rejects( + backend[method](target, { depth: 1 }), + { message: 'LSP server does not support textDocument/prepareCallHierarchy' }, + ); + } + } + } finally { + await cleanupProject(fixture, backend); + } +}); + test('a definition timeout kills the peer and leaves the backend terminal', { timeout: 15_000 }, async () => { const fixture = await makeProject(); const requestMarker = join(fixture.root, 'definition.requested'); diff --git a/tests/lsp.test.ts b/tests/lsp.test.ts index 2306bf5..397a4bd 100644 --- a/tests/lsp.test.ts +++ b/tests/lsp.test.ts @@ -40,6 +40,7 @@ test('unused and depth-zero LSP facades do not start a backend', async () => { }); assert.deepEqual(await facade.tools.callers(target, { depth: 0 }), []); + assert.deepEqual(await facade.tools.callees(target, { depth: 0 }), []); await assert.rejects( facade.tools.callers({ filepath: target.filepath, line: 0 }, { depth: 0 }), /positive safe integer/, diff --git a/tests/ruby-lsp-support.ts b/tests/ruby-lsp-support.ts new file mode 100644 index 0000000..461b002 --- /dev/null +++ b/tests/ruby-lsp-support.ts @@ -0,0 +1,36 @@ +import { execFileSync } from 'node:child_process'; +import { cp, readdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import type { Sandbox } from './support.js'; + +const FIXTURE_ROOT = fileURLToPath(new URL('./fixtures/ruby-lsp/', import.meta.url)); +const GEM_PATH = execFileSync('ruby', [ + '-rrubygems', + '-e', + 'print Gem.path.join(File::PATH_SEPARATOR)', +], { encoding: 'utf8', env: process.env }); + +export async function createRubyLspFixture(target: Sandbox): Promise { + const projectFixture = join(FIXTURE_ROOT, 'project'); + for (const entry of await readdir(projectFixture)) { + await cp(join(projectFixture, entry), join(target.cwd, entry), { recursive: true }); + } + await cp(join(FIXTURE_ROOT, 'project-dependency'), join(target.root, 'project-dependency'), { recursive: true }); + + const rubyOpt = target.env.RUBYOPT === undefined + ? '-rbundler/setup' + : `${target.env.RUBYOPT} -rbundler/setup`; + return { + ...target, + env: { + ...target.env, + BUNDLE_GEMFILE: join(target.cwd, 'Gemfile'), + BUNDLE_IGNORE_CONFIG: '1', + BUNDLE_FROZEN: 'true', + RUBYOPT: rubyOpt, + GEM_PATH, + }, + }; +} diff --git a/tests/ruby-lsp.test.ts b/tests/ruby-lsp.test.ts new file mode 100644 index 0000000..af81bf8 --- /dev/null +++ b/tests/ruby-lsp.test.ts @@ -0,0 +1,231 @@ +import assert from 'node:assert/strict'; +import { readFile, writeFile } from 'node:fs/promises'; +import { isAbsolute, join, relative, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +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 { createSandbox } from './support.ts'; +import { createRubyLspFixture } from './ruby-lsp-support.ts'; + +interface RubyLspGoldens { + readonly widgetDefinitions: readonly { readonly path: string; readonly line: number }[]; + readonly unicodeWidgetDefinitions: readonly { readonly path: string; readonly line: number }[]; + readonly widgetReferences: readonly { readonly path: string; readonly line: number }[]; + readonly widgetDocumentSymbols: readonly RubySymbolView[]; + readonly widgetWorkspaceSymbols: readonly RubySymbolView[]; + readonly supportWidgetDefinitions: readonly { readonly path: string; readonly line: number }[]; +} + +interface RubySymbolView { + readonly name: string; + readonly kind: number; + readonly path: string; + readonly line: number | null; +} + +function relativePath(root: string, path: string): string | undefined { + const candidate = relative(root, path); + return candidate === '..' || candidate.startsWith(`..${sep}`) || isAbsolute(candidate) + ? undefined + : candidate.split(sep).join('/'); +} + +function normalizeUri(uri: string, projectRoot: string, dependencyRoot: string): string { + const path = fileURLToPath(uri); + const projectPath = relativePath(projectRoot, path); + if (projectPath !== undefined) return projectPath; + const dependencyPath = relativePath(dependencyRoot, path); + if (dependencyPath !== undefined) return `/${dependencyPath}`; + throw new Error(`Ruby LSP returned a URI outside the fixture: ${uri}`); +} + +function locationView( + locations: readonly Location[], + projectRoot: string, + dependencyRoot: string, +): readonly { readonly path: string; readonly line: number }[] { + return locations + .map(location => ({ + path: normalizeUri(location.uri, projectRoot, dependencyRoot), + line: location.range.start.line + 1, + })) + .sort((left, right) => left.path.localeCompare(right.path) || left.line - right.line); +} + +function documentSymbolView( + symbols: readonly DocumentSymbol[] | readonly SymbolInformation[], + filepath: string, +): readonly RubySymbolView[] { + const result: RubySymbolView[] = []; + const visit = (items: readonly (DocumentSymbol | SymbolInformation)[]): void => { + for (const symbol of items) { + if ('location' in symbol) { + result.push({ + name: symbol.name, + kind: symbol.kind, + path: filepath, + line: symbol.location.range.start.line + 1, + }); + } else { + result.push({ + name: symbol.name, + kind: symbol.kind, + path: filepath, + line: symbol.selectionRange.start.line + 1, + }); + if (symbol.children !== undefined) visit(symbol.children); + } + } + }; + visit(symbols); + return result; +} + +function workspaceSymbolView( + symbols: readonly (SymbolInformation | WorkspaceSymbol)[], + projectRoot: string, + dependencyRoot: string, +): readonly RubySymbolView[] { + return symbols + .map(symbol => ({ + name: symbol.name, + kind: symbol.kind, + path: normalizeUri(symbol.location.uri, projectRoot, dependencyRoot), + line: 'range' in symbol.location ? symbol.location.range.start.line + 1 : null, + })) + .sort((left, right) => left.path.localeCompare(right.path) + || left.name.localeCompare(right.name) + || (left.line ?? 0) - (right.line ?? 0)); +} + +test('real Ruby LSP navigation matches the fixture golden', { timeout: 180_000 }, async () => { + const sandboxes = createSandbox('ruby-lsp-test-'); + const fixture = await createRubyLspFixture(sandboxes.isolated('ruby')); + await writeFile(join(fixture.cwd, 'lib', 'eof.rb'), '"𝄞"'); + 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, + })); + let depthZeroStarts = 0; + const depthZeroSession = createRunLsp(project, async () => { + depthZeroStarts += 1; + throw new Error('depth-zero call unexpectedly started Ruby LSP'); + }); + + try { + const goldenPath = fileURLToPath(new URL('./fixtures/ruby-lsp/expected.json', import.meta.url)); + const golden = JSON.parse(await readFile(goldenPath, 'utf8')) as RubyLspGoldens; + const { tools } = session; + const widgetTarget = { filepath: 'lib/usage.rb', line: 4, column: 1 }; + const widgetDefinition = locationView( + await tools.definitions(widgetTarget), + fixture.cwd, + dependencyRoot, + ); + assert.deepEqual(widgetDefinition, golden.widgetDefinitions); + assert.deepEqual(widgetDefinition, [{ path: 'lib/widget.rb', line: 2 }]); + + const unicodeDefinition = locationView( + await tools.definitions({ filepath: 'lib/unicode.rb', line: 2, column: 7 }), + fixture.cwd, + dependencyRoot, + ); + assert.deepEqual(unicodeDefinition, golden.unicodeWidgetDefinitions); + assert.deepEqual(unicodeDefinition, [{ path: 'lib/widget.rb', line: 2 }]); + assert.deepEqual( + await tools.definitions({ filepath: 'lib/eof.rb', line: 1, column: 5 }), + [], + 'EOF after a surrogate pair is a valid query position', + ); + + const references = locationView( + await tools.references( + { filepath: 'lib/widget.rb', line: 2, column: 7 }, + { includeDeclaration: true }, + ), + fixture.cwd, + dependencyRoot, + ); + assert.deepEqual(references, golden.widgetReferences); + assert.deepEqual(references.map(reference => reference.path).sort(), [ + 'lib/unicode.rb', + 'lib/usage.rb', + 'lib/widget.rb', + ]); + + const hover = await tools.hover(widgetTarget); + assert.ok(hover !== null && JSON.stringify(hover).includes('Widget')); + + const documentSymbols = documentSymbolView( + await tools.documentSymbols('lib/widget.rb'), + 'lib/widget.rb', + ); + assert.deepEqual(documentSymbols, golden.widgetDocumentSymbols); + assert.ok(documentSymbols.some(symbol => symbol.name === 'Widget' && symbol.kind === 5)); + + const workspaceSymbols = workspaceSymbolView( + await tools.workspaceSymbols('Widget'), + fixture.cwd, + dependencyRoot, + ); + assert.deepEqual(workspaceSymbols, golden.widgetWorkspaceSymbols); + assert.ok(workspaceSymbols.some(symbol => + symbol.name === 'Widget' && symbol.path === 'lib/widget.rb' && symbol.line === 2, + )); + + const supportTarget = { filepath: 'lib/usage.rb', line: 5, column: 1 }; + assert.deepEqual( + await tools.definitions(supportTarget), + [], + ); + const supportDefinition = locationView( + await tools.definitions(supportTarget, { includeDependencies: true }), + fixture.cwd, + dependencyRoot, + ); + assert.deepEqual(supportDefinition, golden.supportWidgetDefinitions); + assert.deepEqual(supportDefinition, [{ path: '/lib/support_widget.rb', line: 1 }]); + + await assert.rejects(tools.declarations(widgetTarget), { + message: 'LSP server does not support textDocument/declaration', + }); + await assert.rejects(tools.typeDefinitions(widgetTarget), { + message: 'LSP server does not support textDocument/typeDefinition', + }); + await assert.rejects(tools.implementations(widgetTarget), { + 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); + } finally { + try { + await session.close(); + } finally { + try { + await depthZeroSession.close(); + } finally { + sandboxes.cleanup(); + } + } + } +}); diff --git a/tests/rule-resolution.test.ts b/tests/rule-resolution.test.ts index f48f0cf..489bf56 100644 --- a/tests/rule-resolution.test.ts +++ b/tests/rule-resolution.test.ts @@ -711,7 +711,7 @@ function startWorker(prelude = ''): Worker { async function requestWorker( worker: Worker, - request: Omit, + request: Omit, ): Promise { const result = Promise.withResolvers(); const onMessage = (message: RuleTestWorkerMessage) => { @@ -720,18 +720,18 @@ async function requestWorker( const onError = (error: Error) => result.reject(error); const onExit = (code: number) => result.reject(new Error(`Worker exited before a result: ${code}`)); const channel = new MessageChannel(); - const host = createRuleTestLspHost( - channel.port1, - createProject(request.root, { excludeFiles: [request.ruleFile] }), - ); + const rubyChannel = new MessageChannel(); + const project = createProject(request.root, { excludeFiles: [request.ruleFile] }); + const host = createRuleTestLspHost(channel.port1, project); + const rubyHost = createRuleTestLspHost(rubyChannel.port1, project); worker.on('message', onMessage); worker.on('error', onError); worker.on('exit', onExit); let transferred = false; try { worker.postMessage( - { ...request, lspPort: channel.port2 } satisfies RuleTestWorkerRequest, - [channel.port2], + { ...request, lspPort: channel.port2, rubyLspPort: rubyChannel.port2 } satisfies RuleTestWorkerRequest, + [channel.port2, rubyChannel.port2], ); transferred = true; return await result.promise; @@ -740,10 +740,16 @@ async function requestWorker( worker.off('error', onError); worker.off('exit', onExit); try { - await host.close(); + const closes = await Promise.allSettled([host.close(), rubyHost.close()]); + const failed = closes.find((result): result is PromiseRejectedResult => result.status === 'rejected'); + if (failed !== undefined) throw failed.reason; } finally { channel.port1.close(); - if (!transferred) channel.port2.close(); + rubyChannel.port1.close(); + if (!transferred) { + channel.port2.close(); + rubyChannel.port2.close(); + } } } } diff --git a/tests/rule-test-pool.test.ts b/tests/rule-test-pool.test.ts index 0db463c..2a22ff6 100644 --- a/tests/rule-test-pool.test.ts +++ b/tests/rule-test-pool.test.ts @@ -445,66 +445,105 @@ test('inline rule tests close one fresh parent-owned LSP backend per successful target.cwd, ruleFile, inlineRule( - `async (_subject, context) => { await context.goLsp.hover({ filepath: 'inputs/one.txt', line: 1 }); return pass(); }`, + `async (_subject, context) => { + await context.goLsp.hover({ filepath: 'inputs/one.txt', line: 1 }); + await context.rubyLsp.hover({ filepath: 'inputs/one.txt', line: 1 }); + return pass(); + }`, cases(['first', 'second']), ), ); - const openedRoots: string[] = []; - const closedRoots: string[] = []; - const rootsAtClose: boolean[] = []; + const openedRoots = { go: [] as string[], ruby: [] as string[] }; + const closedRoots = { go: [] as string[], ruby: [] as string[] }; + const rootsAtClose = { go: [] as boolean[], ruby: [] as boolean[] }; const createBackend: LspBackendFactory = async project => { - openedRoots.push(project.root); + openedRoots.go.push(project.root); return lspBackend(() => { - closedRoots.push(project.root); - rootsAtClose.push(existsSync(project.root)); + closedRoots.go.push(project.root); + rootsAtClose.go.push(existsSync(project.root)); + }, async () => null); + }; + const createRubyBackend: LspBackendFactory = async project => { + openedRoots.ruby.push(project.root); + return lspBackend(() => { + closedRoots.ruby.push(project.root); + rootsAtClose.ruby.push(existsSync(project.root)); }, async () => null); }; - const results = await runRuleTests(rules, undefined, 10_000, 1, false, false, createBackend); + const results = await runRuleTests(rules, undefined, 10_000, 1, false, false, createBackend, createRubyBackend); assert.deepEqual(results.map(result => result.actual), ['pass', 'pass']); - assert.equal(openedRoots.length, 2); - assert.equal(new Set(openedRoots).size, 2, 'each reused-worker case must get its own backend'); - assert.deepEqual(closedRoots, openedRoots); - assert.deepEqual(rootsAtClose, [true, true], 'the parent backend must close before its case root is removed'); - for (const root of openedRoots) assert.equal(existsSync(root), false, root); + assert.equal(openedRoots.go.length, 2); + assert.equal(openedRoots.ruby.length, 2); + assert.equal(new Set(openedRoots.go).size, 2, 'each reused-worker case must get its own Go backend'); + assert.equal(new Set(openedRoots.ruby).size, 2, 'each reused-worker case must get its own Ruby backend'); + assert.deepEqual(openedRoots.ruby, openedRoots.go, 'both hosts must use the same case project'); + assert.deepEqual(closedRoots.go, openedRoots.go); + assert.deepEqual(closedRoots.ruby, openedRoots.ruby); + assert.deepEqual(rootsAtClose.go, [true, true], 'the Go host must close before its case root is removed'); + assert.deepEqual(rootsAtClose.ruby, [true, true], 'the Ruby host must close before its case root is removed'); + for (const root of openedRoots.go) assert.equal(existsSync(root), false, root); + for (const root of openedRoots.ruby) assert.equal(existsSync(root), false, root); }); -test('inline rule timeout closes a pending parent-owned LSP backend before removing its root', { timeout: 20_000 }, async () => { +test('inline rule timeout closes pending Go and Ruby hosts before removing its root', { timeout: 20_000 }, async () => { const target = sandbox.isolated('parent-lsp-timeout'); const ruleFile = join(target.cwd, '.coderuler', 'lsp.rule.ts'); const rules = await loadInlineRule( target.cwd, ruleFile, inlineRule( - `async (_subject, context) => { await context.goLsp.hover({ filepath: 'inputs/one.txt', line: 1 }); return pass(); }`, + `async (_subject, context) => { + await Promise.all([ + context.goLsp.hover({ filepath: 'inputs/one.txt', line: 1 }), + context.rubyLsp.hover({ filepath: 'inputs/one.txt', line: 1 }), + ]); + return pass(); + }`, cases(['pending']), ), ); - const started = Promise.withResolvers(); - const closedRoots: string[] = []; - const rootsAtClose: boolean[] = []; + const started = Promise.withResolvers<{ go: string; ruby: string }>(); + const roots = { go: undefined as string | undefined, ruby: undefined as string | undefined }; + const closedRoots = { go: [] as string[], ruby: [] as string[] }; + const rootsAtClose = { go: [] as boolean[], ruby: [] as boolean[] }; + const onStarted = (): void => { + if (roots.go !== undefined && roots.ruby !== undefined) started.resolve({ go: roots.go, ruby: roots.ruby }); + }; const createBackend: LspBackendFactory = async (project, signal) => { - started.resolve(project.root); + roots.go = project.root; + onStarted(); return lspBackend(() => { - closedRoots.push(project.root); - rootsAtClose.push(existsSync(project.root)); + closedRoots.go.push(project.root); + rootsAtClose.go.push(existsSync(project.root)); + }, () => hoverUntilAbort(signal)); + }; + const createRubyBackend: LspBackendFactory = async (project, signal) => { + roots.ruby = project.root; + onStarted(); + return lspBackend(() => { + closedRoots.ruby.push(project.root); + rootsAtClose.ruby.push(existsSync(project.root)); }, () => hoverUntilAbort(signal)); }; // This exercises the real worker-thread deadline, which cannot be driven by parent-side fake time. - const execution = runRuleTests(rules, undefined, 4_000, 1, false, false, createBackend); + const execution = runRuleTests(rules, undefined, 4_000, 1, false, false, createBackend, createRubyBackend); const startedOrFinished = await Promise.race([ - started.promise.then(root => ({ root })), + started.promise.then(projectRoots => ({ projectRoots })), execution.then(() => ({ finished: true }), error => ({ error })), ]); - assert.ok('root' in startedOrFinished, 'the case must reach its parent-owned LSP query before timeout'); + assert.ok('projectRoots' in startedOrFinished, 'the case must reach both parent-owned LSP hosts before timeout'); const [result] = await execution; assert.equal(result?.actual, 'timeout'); - assert.deepEqual(closedRoots, [startedOrFinished.root]); - assert.deepEqual(rootsAtClose, [true]); - assert.equal(existsSync(startedOrFinished.root), false); + assert.deepEqual(closedRoots.go, [startedOrFinished.projectRoots.go]); + assert.deepEqual(closedRoots.ruby, [startedOrFinished.projectRoots.ruby]); + assert.deepEqual(rootsAtClose.go, [true]); + assert.deepEqual(rootsAtClose.ruby, [true]); + assert.equal(existsSync(startedOrFinished.projectRoots.go), false); + assert.equal(existsSync(startedOrFinished.projectRoots.ruby), false); }); -test('inline sibling failure closes a pending parent-owned LSP backend before removing its root', { timeout: 20_000 }, async context => { +test('inline sibling failure closes pending Go and Ruby hosts before removing its root', { timeout: 20_000 }, async context => { const target = sandbox.isolated('parent-lsp-sibling-failure'); const ruleFile = join(target.cwd, '.coderuler', 'lsp.rule.ts'); const gate = await startGate(); @@ -518,7 +557,10 @@ test('inline sibling failure closes a pending parent-owned LSP backend before re await waitFor('lsp-started'); throw new Error('sibling operational failure'); } - await context.goLsp.hover({ filepath: 'inputs/one.txt', line: 1 }); + await Promise.all([ + context.goLsp.hover({ filepath: 'inputs/one.txt', line: 1 }), + context.rubyLsp.hover({ filepath: 'inputs/one.txt', line: 1 }), + ]); return pass(); }`, `test('pending').withFiles({ 'inputs/one.txt': 'pending' }).passes(), @@ -526,27 +568,43 @@ test('sibling').withFiles({ 'inputs/one.txt': 'sibling' }).passes()`, gateHelpers(gate.url), ), ); - const started = Promise.withResolvers(); - const closedRoots: string[] = []; - const rootsAtClose: boolean[] = []; + const started = Promise.withResolvers<{ go: string; ruby: string }>(); + const roots = { go: undefined as string | undefined, ruby: undefined as string | undefined }; + const closedRoots = { go: [] as string[], ruby: [] as string[] }; + const rootsAtClose = { go: [] as boolean[], ruby: [] as boolean[] }; + const onStarted = (): void => { + if (roots.go !== undefined && roots.ruby !== undefined) started.resolve({ go: roots.go, ruby: roots.ruby }); + }; const createBackend: LspBackendFactory = async (project, signal) => { - started.resolve(project.root); + roots.go = project.root; + onStarted(); return lspBackend(() => { - closedRoots.push(project.root); - rootsAtClose.push(existsSync(project.root)); + closedRoots.go.push(project.root); + rootsAtClose.go.push(existsSync(project.root)); }, () => hoverUntilAbort(signal)); }; - const execution = runRuleTests(rules, undefined, 15_000, 2, false, false, createBackend); + const createRubyBackend: LspBackendFactory = async (project, signal) => { + roots.ruby = project.root; + onStarted(); + return lspBackend(() => { + closedRoots.ruby.push(project.root); + rootsAtClose.ruby.push(existsSync(project.root)); + }, () => hoverUntilAbort(signal)); + }; + const execution = runRuleTests(rules, undefined, 15_000, 2, false, false, createBackend, createRubyBackend); const startedOrFinished = await Promise.race([ - started.promise.then(root => ({ root })), + started.promise.then(projectRoots => ({ projectRoots })), execution.then(() => ({ finished: true }), error => ({ error })), ]); - assert.ok('root' in startedOrFinished, 'the pending sibling must start its LSP query before failure'); + assert.ok('projectRoots' in startedOrFinished, 'the pending sibling must start both LSP hosts before failure'); gate.signal('lsp-started'); await assert.rejects(execution, /sibling operational failure/); - assert.deepEqual(closedRoots, [startedOrFinished.root]); - assert.deepEqual(rootsAtClose, [true]); - assert.equal(existsSync(startedOrFinished.root), false); + assert.deepEqual(closedRoots.go, [startedOrFinished.projectRoots.go]); + assert.deepEqual(closedRoots.ruby, [startedOrFinished.projectRoots.ruby]); + assert.deepEqual(rootsAtClose.go, [true]); + assert.deepEqual(rootsAtClose.ruby, [true]); + assert.equal(existsSync(startedOrFinished.projectRoots.go), false); + assert.equal(existsSync(startedOrFinished.projectRoots.ruby), false); }); test('inline sibling LSP cleanup failure preserves the first rule failure', { timeout: 20_000 }, async context => { @@ -563,7 +621,10 @@ test('inline sibling LSP cleanup failure preserves the first rule failure', { ti await waitFor('lsp-started'); throw new Error('first rule failure'); } - await context.goLsp.hover({ filepath: 'inputs/one.txt', line: 1 }); + await Promise.all([ + context.goLsp.hover({ filepath: 'inputs/one.txt', line: 1 }), + context.rubyLsp.hover({ filepath: 'inputs/one.txt', line: 1 }), + ]); return pass(); }`, `test('pending').withFiles({ 'inputs/one.txt': 'pending' }).passes(), @@ -571,77 +632,117 @@ test('failure').withFiles({ 'inputs/one.txt': 'failure' }).passes()`, gateHelpers(gate.url), ), ); - const started = Promise.withResolvers(); - const closedRoots: string[] = []; - const createBackend: LspBackendFactory = async (project, signal) => lspBackend( - () => { - closedRoots.push(project.root); - throw new Error('sibling LSP cleanup failure'); - }, - () => { - started.resolve(project.root); - return hoverUntilAbort(signal); - }, - ); - const execution = runRuleTests(rules, undefined, 15_000, 2, false, false, createBackend); + const started = Promise.withResolvers<{ go: string; ruby: string }>(); + const roots = { go: undefined as string | undefined, ruby: undefined as string | undefined }; + const closedRoots = { go: [] as string[], ruby: [] as string[] }; + const rootsAtClose = { go: [] as boolean[], ruby: [] as boolean[] }; + const onStarted = (): void => { + if (roots.go !== undefined && roots.ruby !== undefined) started.resolve({ go: roots.go, ruby: roots.ruby }); + }; + const createBackend: LspBackendFactory = async (project, signal) => { + roots.go = project.root; + onStarted(); + return lspBackend(() => { + closedRoots.go.push(project.root); + rootsAtClose.go.push(existsSync(project.root)); + throw new Error('Go host cleanup failure'); + }, () => hoverUntilAbort(signal)); + }; + const createRubyBackend: LspBackendFactory = async (project, signal) => { + roots.ruby = project.root; + onStarted(); + return lspBackend(() => { + closedRoots.ruby.push(project.root); + rootsAtClose.ruby.push(existsSync(project.root)); + }, () => hoverUntilAbort(signal)); + }; + const execution = runRuleTests(rules, undefined, 15_000, 2, false, false, createBackend, createRubyBackend); const startedOrFinished = await Promise.race([ - started.promise.then(root => ({ root })), + started.promise.then(projectRoots => ({ projectRoots })), execution.then(() => ({ finished: true }), error => ({ error })), ]); - assert.ok('root' in startedOrFinished, 'the pending sibling must start its LSP query before failure'); + assert.ok('projectRoots' in startedOrFinished, 'the pending sibling must start both LSP hosts before failure'); + assert.equal(startedOrFinished.projectRoots.go, startedOrFinished.projectRoots.ruby); gate.signal('lsp-started'); await assert.rejects(execution, error => { const message = error instanceof Error ? error.message : String(error); assert.match(message, /first rule failure/); - assert.doesNotMatch(message, /sibling LSP cleanup failure/); + assert.doesNotMatch(message, /Go host cleanup failure/); return true; }); - assert.deepEqual(closedRoots, [startedOrFinished.root]); - assert.equal(existsSync(startedOrFinished.root), false); + assert.deepEqual(closedRoots.go, [startedOrFinished.projectRoots.go]); + assert.deepEqual(closedRoots.ruby, [startedOrFinished.projectRoots.ruby], 'Ruby host cleanup must run after Go cleanup fails'); + assert.deepEqual(rootsAtClose.go, [true]); + assert.deepEqual(rootsAtClose.ruby, [true]); + assert.equal(existsSync(startedOrFinished.projectRoots.go), false); }); -test('inline rule worker failure takes precedence over parent-owned LSP cleanup failure', { timeout: 20_000 }, async () => { +test('inline rule worker failure takes precedence over both parent-owned LSP cleanup paths', { timeout: 20_000 }, async () => { const target = sandbox.isolated('parent-lsp-cleanup-precedence'); const ruleFile = join(target.cwd, '.coderuler', 'lsp.rule.ts'); const rules = await loadInlineRule( target.cwd, ruleFile, inlineRule( - `async (_subject, context) => { await context.goLsp.hover({ filepath: 'inputs/one.txt', line: 1 }); throw new Error('original worker failure'); }`, + `async (_subject, context) => { + await Promise.all([ + context.goLsp.hover({ filepath: 'inputs/one.txt', line: 1 }), + context.rubyLsp.hover({ filepath: 'inputs/one.txt', line: 1 }), + ]); + throw new Error('original worker failure'); + }`, cases(['failure']), ), ); + const closed: string[] = []; const createBackend: LspBackendFactory = async () => lspBackend( - () => { throw new Error('LSP host cleanup failure'); }, + () => { closed.push('go'); throw new Error('Go host cleanup failure'); }, + async () => null, + ); + const createRubyBackend: LspBackendFactory = async () => lspBackend( + () => { closed.push('ruby'); }, async () => null, ); await assert.rejects( - runRuleTests(rules, undefined, 10_000, 1, false, false, createBackend), + runRuleTests(rules, undefined, 10_000, 1, false, false, createBackend, createRubyBackend), /original worker failure/, ); + assert.deepEqual([...closed].sort(), ['go', 'ruby']); }); -test('inline rule test reports a standalone parent-owned LSP cleanup failure', { timeout: 20_000 }, async () => { +test('inline rule test reports a standalone parent-owned LSP cleanup failure after closing both hosts', { timeout: 20_000 }, async () => { const target = sandbox.isolated('parent-lsp-cleanup-standalone'); const ruleFile = join(target.cwd, '.coderuler', 'lsp.rule.ts'); const rules = await loadInlineRule( target.cwd, ruleFile, inlineRule( - `async (_subject, context) => { await context.goLsp.hover({ filepath: 'inputs/one.txt', line: 1 }); return pass(); }`, + `async (_subject, context) => { + await Promise.all([ + context.goLsp.hover({ filepath: 'inputs/one.txt', line: 1 }), + context.rubyLsp.hover({ filepath: 'inputs/one.txt', line: 1 }), + ]); + return pass(); + }`, cases(['success']), ), ); + const closed: string[] = []; const createBackend: LspBackendFactory = async () => lspBackend( - () => { throw new Error('standalone LSP cleanup failure'); }, + () => { closed.push('go'); throw new Error('standalone LSP cleanup failure'); }, + async () => null, + ); + const createRubyBackend: LspBackendFactory = async () => lspBackend( + () => { closed.push('ruby'); }, async () => null, ); await assert.rejects( - runRuleTests(rules, undefined, 10_000, 1, false, false, createBackend), + runRuleTests(rules, undefined, 10_000, 1, false, false, createBackend, createRubyBackend), /standalone LSP cleanup failure/, ); + assert.deepEqual([...closed].sort(), ['go', 'ruby']); }); test('test rejects concurrency values that are not positive safe integers', async context => { diff --git a/tests/structured-scopes.test.ts b/tests/structured-scopes.test.ts index e2f3540..fec1855 100644 --- a/tests/structured-scopes.test.ts +++ b/tests/structured-scopes.test.ts @@ -58,7 +58,12 @@ function scope(id: string, source: string, lines: LineSpan, data?: unknown): Rec } const project = createProject(ROOT); -const projectContext: ProjectContext = { project, goLsp: createRunLsp(project).tools, trace: () => {} }; +const projectContext: ProjectContext = { + project, + goLsp: createRunLsp(project).tools, + rubyLsp: createRunLsp(project).tools, + trace: () => {}, +}; function collect( selector: ScopeSelector, source: string, diff --git a/website/src/content/docs/writing-rules.md b/website/src/content/docs/writing-rules.md index 2d39605..4c92a1e 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. LSP use is optional; install Go and `gopls` only if a rule calls these helpers. +`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. LSP use is optional; install Go and `gopls` or Shopify Ruby LSP (`ruby-lsp`) on `PATH` only if a rule uses that language's helpers. CodeRuler does not install either server. -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 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`. +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 only) 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`. -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. 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. For example, call graph helpers inside an async custom check: