/*
* Copyright 2025 the original author or authors.
*
* Licensed under the Moderne Source Available License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://docs.moderne.io/licensing/moderne-source-available-license
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as rpc from "vscode-jsonrpc/node";
import {MessageConnection} from "vscode-jsonrpc/node";
import {Cursor, isSourceFile, isTree, rootCursor, SourceFile, Tree} from "../tree";
import {Recipe} from "../recipe";
import {SnowflakeId} from "@akashrajpurohit/snowflake-id";
import {
DependencyTypes,
Generate,
GenerateResponse,
GetObject,
GetMarketplace,
GetMarketplaceResponseRow,
toMarketplace,
Parse,
ParseProject,
PrepareRecipe,
PrepareRecipeResponse,
Print,
TraceGetObject,
Visit,
VisitResponse,
BatchVisit,
SetDataTableStore
} from "./request";
import {DataTableStore} from "../data-table";
import {RecipeMarketplace} from "../marketplace";
import {initializeMetricsCsv, setCacheSizeProvider} from "./request/metrics";
import {RpcObjectData, RpcObjectState, RpcReceiveQueue} from "./queue";
import {RpcRecipe} from "./recipe";
import {ExecutionContext} from "../execution";
import {InstallRecipes, InstallRecipesResponse} from "./request/install-recipes";
import {ParserInput} from "../parser";
import {ReferenceMap} from "../reference";
import {GetLanguages} from "./request/get-languages";
export class RewriteRpc {
/**
* Key for the active {@link RewriteRpc} connection on {@link globalThis}.
*
* It deliberately lives on `globalThis` rather than as a `static` field:
* a recipe package that bundles `@openrewrite/rewrite` (or resolves it from
* its own `node_modules`) loads a *separate copy* of this module, with its
* own class object and therefore its own statics. A `static` field set by
* the host would be invisible to such a copy, so `RewriteRpc.get()` (e.g.
* via `prepareJavaRecipe`) would return `undefined` and throw "no active
* RewriteRpc connection" — surfacing during `InstallRecipes` as the
* misleading "Ensure the constructor can be called without any arguments".
* {@link Symbol.for} resolves to the same symbol across every module copy,
* so all copies share the one active connection. See gh-7968.
*/
private static readonly GLOBAL_KEY: symbol = Symbol.for("org.openrewrite.rpc.RewriteRpc.global");
private readonly snowflake = SnowflakeId();
readonly localObjects: Map any) | any> = new Map();
/* A reverse map of the objects back to their IDs */
private readonly localObjectIds = new IdentityMap();
readonly remoteObjects: Map = new Map();
readonly remoteRefs: Map = new Map();
readonly localRefs: ReferenceMap = new ReferenceMap();
// Ref high-water per source file, captured before it is first visited so an Evict rolls
// back exactly the refs it introduced. `send` = localRefs snapshot, `recvMax` = max remoteRefs key.
readonly refCheckpoints: Map = new Map();
private remoteLanguages?: string[];
private readonly logger?: rpc.Logger;
private traceGetObject: TraceGetObject = {receive: false, send: false};
private configuredDataTableStore?: DataTableStore;
constructor(readonly connection: MessageConnection = rpc.createMessageConnection(
new rpc.StreamMessageReader(process.stdin),
new rpc.StreamMessageWriter(process.stdout),
),
options: {
batchSize?: number,
marketplace?: RecipeMarketplace,
logger?: rpc.Logger,
metricsCsv?: string,
recipeInstallDir?: string
}) {
// Initialize metrics CSV file if configured
initializeMetricsCsv(options.metricsCsv, options.logger);
setCacheSizeProvider(() => ({
local: this.localObjects.size,
remote: this.remoteObjects.size,
refs: this.remoteRefs.size + this.localRefs.size,
}));
this.logger = options.logger;
const preparedRecipes: Map = new Map();
const recipeCursors: WeakMap = new WeakMap()
// Need this indirection, otherwise `this` will be undefined when executed in the handlers.
const getObject = (id: string, sourceFileType?: string) => this.getObject(id, sourceFileType);
const getCursor = (cursorIds: string[] | undefined, sourceFileType?: string) => this.getCursor(cursorIds, sourceFileType);
// First visit of the file wins.
const captureRefCheckpoint = (treeId: string) => {
if (this.refCheckpoints.has(treeId)) {
return;
}
let recvMax = -1;
for (const k of this.remoteRefs.keys()) {
if (k > recvMax) {
recvMax = k;
}
}
this.refCheckpoints.set(treeId, {send: this.localRefs.snapshot(), recvMax});
};
const traceGetObject = () => this.traceGetObject.send;
const dataTableStore = () => this.configuredDataTableStore;
const marketplace = options.marketplace || new RecipeMarketplace();
// Recipe name -> the package that contributed it, recorded during InstallRecipes and read when
// GetMarketplace builds rows so the host can attribute each recipe to its own bundle.
const recipeOrigin: Map = new Map();
Visit.handle(this.connection, this.localObjects, preparedRecipes, recipeCursors, getObject, captureRefCheckpoint, getCursor, dataTableStore, options.metricsCsv);
BatchVisit.handle(this.connection, this.localObjects, preparedRecipes, recipeCursors, getObject, captureRefCheckpoint, getCursor, dataTableStore, options.metricsCsv);
Generate.handle(this.connection, this.localObjects, preparedRecipes, recipeCursors, getObject, dataTableStore, options.metricsCsv);
SetDataTableStore.handle(this.connection, store => this.configuredDataTableStore = store, options.metricsCsv);
GetObject.handle(this.connection, this.remoteObjects, this.localObjects,
this.localRefs, options?.batchSize || 1000, traceGetObject, options.metricsCsv);
GetMarketplace.handle(this.connection, marketplace, recipeOrigin, options.metricsCsv);
GetLanguages.handle(this.connection, options.metricsCsv);
PrepareRecipe.handle(this.connection, marketplace, preparedRecipes, options.metricsCsv);
Parse.handle(this.connection, this.localObjects, options.metricsCsv);
ParseProject.handle(this.connection, this.localObjects, options.metricsCsv);
DependencyTypes.handle(this.connection, options?.batchSize || 1000, options.metricsCsv);
Print.handle(this.connection, getObject, options.logger, options.metricsCsv);
InstallRecipes.handle(this.connection, options.recipeInstallDir ?? ".rewrite", marketplace, recipeOrigin, options.logger, options.metricsCsv);
this.connection.onRequest(
new rpc.RequestType("TraceGetObject"),
async (request) => {
this.traceGetObject = request;
return true;
}
)
// Clears local caches. Captured here so it can close over the
// constructor-local `preparedRecipes` while still being callable
// from the public `reset()` method on the class.
const clearLocalState = () => {
this.localObjects.clear();
this.localObjectIds.clear();
this.remoteObjects.clear();
this.remoteRefs.clear();
this.localRefs.clear();
this.refCheckpoints.clear();
preparedRecipes.clear();
this.remoteLanguages = undefined;
};
this.clearLocalState = clearLocalState;
this.connection.onRequest(
new rpc.RequestType0("Reset"),
async () => {
// Inbound Reset only clears local state — never sends a Reset
// back to the originator. Mirrors the Java handler in
// RewriteRpc.java around line 222.
clearLocalState();
return true;
}
)
// Drop one source file's tree + roll back the refs it introduced. Fire-and-forget
// notification (no reply), so recipe/accumulator/context state is left intact.
this.connection.onNotification(
new rpc.NotificationType<{ id: string }>("Evict"),
(params) => {
const id = params.id;
this.localObjects.delete(id);
this.remoteObjects.delete(id);
const cp = this.refCheckpoints.get(id);
if (cp !== undefined) {
this.localRefs.rollbackTo(cp.send);
for (const k of [...this.remoteRefs.keys()]) {
if (k > cp.recvMax) {
this.remoteRefs.delete(k);
}
}
this.refCheckpoints.delete(id);
}
}
)
RewriteRpc.set(this);
this.connection.listen();
}
private readonly clearLocalState!: () => void;
/**
* Reset both the remote and local RPC caches. Sends a `Reset` request to the
* remote — which clears the remote's state without sending one back — and
* then clears local caches. Use this between independent operations (e.g.
* between tests) so accumulated objects and prepared recipes don't leak
* across boundaries.
*/
async reset(): Promise {
await this.connection.sendRequest(
new rpc.RequestType0("Reset"),
);
this.clearLocalState();
}
static set(value: RewriteRpc) {
(globalThis as any)[RewriteRpc.GLOBAL_KEY] = value;
}
static get(): RewriteRpc | undefined {
return (globalThis as any)[RewriteRpc.GLOBAL_KEY];
}
end(): RewriteRpc {
this.connection.end();
return this;
}
async getObject(id: string, sourceFileType?: string): Promise
{
// Use the last synced state as the baseline for receiving diffs.
// This must match what the remote used as its baseline when computing the diff.
// Using localObjects here would be wrong if the local side modified the tree
// (e.g., via a local recipe) since the remote doesn't know about those changes.
const before = this.remoteObjects.get(id);
const q = new RpcReceiveQueue(this.remoteRefs, sourceFileType, () => {
return this.connection.sendRequest(
new rpc.RequestType("GetObject"),
new GetObject(id, sourceFileType),
);
}, this.logger, this.traceGetObject.receive);
let remoteObject: P;
try {
remoteObject = await q.receive(before as P);
} catch (e) {
// Reset our tracking of the remote state so the next interaction
// forces a full object sync (ADD) instead of a delta (CHANGE).
this.remoteObjects.delete(id);
throw e;
}
const eof = (await q.take());
if (eof.state !== RpcObjectState.END_OF_OBJECT) {
RpcObjectData.logTrace(eof, this.traceGetObject.receive, this.logger);
throw new Error(`Expected END_OF_OBJECT but got: ${eof.state}`);
}
this.remoteObjects.set(id, remoteObject);
this.localObjects.set(id, remoteObject);
return remoteObject;
}
async getCursor(cursorIds: string[] | undefined, sourceFileType?: string): Promise {
let cursor = rootCursor();
if (cursorIds) {
for (let i = cursorIds.length - 1; i >= 0; i--) {
const cursorObject = await this.getObject(cursorIds[i], sourceFileType);
this.remoteObjects.set(cursorIds[i], cursorObject);
cursor = new Cursor(cursorObject, cursor);
}
}
return cursor;
}
async parse(inputs: ParserInput[], sourceFileType: string, relativeTo?: string): Promise {
const parsed: SourceFile[] = [];
for (const g of await this.connection.sendRequest(
new rpc.RequestType("Parse"),
new Parse(inputs, relativeTo)
)) {
parsed.push(await this.getObject(g, sourceFileType));
}
return parsed;
}
async print(tree: SourceFile): Promise;
async print(tree: Tree, cursor: Cursor): Promise;
async print(tree: Tree, cursor?: Cursor): Promise {
if (!cursor && !isSourceFile(tree)) {
throw new Error("Cursor is required for non-SourceFile trees");
}
this.localObjects.set(tree.id.toString(), tree);
const sourceFile = isSourceFile(tree) ? tree : cursor!.firstEnclosing(t => isSourceFile(t))!;
return await this.connection.sendRequest(
new rpc.RequestType("Print"),
new Print(tree.id, sourceFile.kind)
);
}
async languages(): Promise {
if (!this.remoteLanguages) {
this.remoteLanguages = await this.connection.sendRequest(
new rpc.RequestType0("GetLanguages")
);
}
return this.remoteLanguages;
}
async marketplace(): Promise {
const rows = await this.connection.sendRequest(
new rpc.RequestType0("GetMarketplace")
);
return await toMarketplace(rows);
}
async prepareRecipe(id: string, options?: any): Promise {
const response = await this.connection.sendRequest(
new rpc.RequestType("PrepareRecipe"),
new PrepareRecipe(id, options)
);
return new RpcRecipe(this, response.id, response.descriptor, response.editVisitor,
response.scanVisitor);
}
async visit(tree: Tree, visitorName: string, p: any, cursor?: Cursor): Promise {
this.localObjects.set(tree.id.toString(), tree);
const pId = this.localObject(p);
const cursorIds = this.getCursorIds(cursor);
const sourceFileType = isSourceFile(tree) ? tree.kind :
cursor!.firstEnclosing(t => isSourceFile(t))!.kind;
const response = await this.connection.sendRequest(
new rpc.RequestType("Visit"),
new Visit(visitorName, sourceFileType, undefined, tree.id.toString(), pId, cursorIds)
);
return response.modified ? this.getObject(tree.id.toString(), sourceFileType) : tree;
}
async generate(remoteRecipeId: string, ctx: ExecutionContext): Promise {
const ctxId = this.localObject(ctx);
const generated: SourceFile[] = [];
const response = await this.connection.sendRequest(
new rpc.RequestType("Generate"),
new Generate(remoteRecipeId, ctxId)
);
for (let i = 0; i < response.ids.length; i++) {
generated.push(await this.getObject(response.ids[i], response.sourceFileTypes[i]));
}
return generated;
}
installRecipes(recipes: string | { packageName: string, version?: string }): Promise {
return this.connection.sendRequest(
new rpc.RequestType("InstallRecipes"),
new InstallRecipes(recipes)
);
}
private localObject(obj: P): string {
let id = this.localObjectIds.get(obj);
if (!id) {
id = this.snowflake.generate();
this.localObjects.set(id, obj);
this.localObjectIds.set(obj, id);
}
return id
}
getCursorIds(cursor: Cursor | undefined): string[] | undefined {
if (cursor) {
const cursorIds = [];
for (const c of cursor.asArray()) {
let id: string;
if (isTree(c)) {
id = (c as Tree).id.toString();
this.localObjects.set(id, c);
} else {
id = this.localObject(c);
}
cursorIds.push(id);
}
return cursorIds
}
}
}
class IdentityMap {
constructor(private objectMap = new WeakMap(),
private readonly primitiveMap = new Map()) {
}
set(key: any, value: any): void {
if (typeof key === 'object' && key !== null) {
this.objectMap.set(key, value);
} else {
this.primitiveMap.set(key, value);
}
}
get(key: any): string | undefined {
if (typeof key === 'object' && key !== null) {
return this.objectMap.get(key);
} else {
return this.primitiveMap.get(key);
}
}
has(key: any): boolean {
if (typeof key === 'object' && key !== null) {
return this.objectMap.has(key);
} else {
return this.primitiveMap.has(key);
}
}
clear(): void {
this.objectMap = new WeakMap();
this.primitiveMap.clear();
}
}