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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 3 additions & 3 deletions skills/coderuler-new-rule/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
111 changes: 80 additions & 31 deletions src/adapter/lsp/backend.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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'],
Expand All @@ -277,7 +292,6 @@ export async function createLspBackend(
let closePromise: Promise<void> | undefined;
let connection: ProtocolConnection | undefined;
let capabilities: ServerCapabilities | undefined;
let rubyPositionNeedsSurrogateAdjustment = false;
const controller = new AbortController();
const documentPromises = new Map<string, Promise<Document>>();
const symbolPromises = new Map<string, Promise<readonly DocumentSymbol[] | readonly SymbolInformation[]>>();
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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));

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<readonly CallHierarchyItem[]> => {
if (rubyCallGraph !== undefined) return rubyCallGraph.roots(target, includeDependencies);
assertCapability('textDocument/prepareCallHierarchy', 'callHierarchyProvider');
const document = await documentForPath(target.filepath, includeDependencies);
let position: Position;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
};

Expand Down
90 changes: 90 additions & 0 deletions src/adapter/lsp/coderuler-ruby-lsp-compat.rb
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading