/*
* 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 {Recipe, ScanningRecipe} from "../../recipe";
import {Cursor, rootCursor, SourceFile, Tree} from "../../tree";
import {TreeVisitor} from "../../visitor";
import {ExecutionContext} from "../../execution";
import {DATA_TABLE_STORE, DataTableStore} from "../../data-table";
import {withMetrics, extractSourcePath} from "./metrics";
import {lookupVisitor} from "./visitor-registry";
export interface VisitResponse {
modified: boolean
}
// Tracks the last phase (scan or edit) for each recipe to detect cycle transitions
type RecipePhase = 'scan' | 'edit';
const recipePhases: WeakMap = new WeakMap();
export class Visit {
constructor(readonly visitor: string,
readonly sourceFileType: string,
readonly visitorOptions: Map | undefined,
readonly treeId: string,
readonly p: string,
readonly cursor: string[] | undefined) {
}
static handle(connection: rpc.MessageConnection,
localObjects: Map,
preparedRecipes: Map,
recipeCursors: WeakMap,
getObject: (id: string, sourceFileType?: string) => any,
captureRefCheckpoint: (treeId: string) => void,
getCursor: (cursorIds: string[] | undefined, sourceFileType?: string) => Promise,
dataTableStore: () => DataTableStore | undefined,
metricsCsv?: string): void {
connection.onRequest(
new rpc.RequestType("Visit"),
withMetrics(
"Visit",
metricsCsv,
(context) => async (request) => {
const p = await getObject(request.p, undefined);
const store = dataTableStore();
if (store && p instanceof ExecutionContext) {
p.messages[DATA_TABLE_STORE] = store;
}
captureRefCheckpoint(request.treeId);
const before: Tree = await getObject(request.treeId, request.sourceFileType);
const cursor = await getCursor(request.cursor, request.sourceFileType);
context.target = extractSourcePath(before, cursor);
localObjects.set(before.id.toString(), before);
const visitor = await Visit.instantiateVisitor(request, preparedRecipes, recipeCursors, p);
const after = await visitor.visit(before, p, cursor);
if (!after) {
localObjects.delete(before.id.toString());
} else if (after !== before) {
localObjects.set(after.id.toString(), after);
}
return {modified: before !== after};
}
)
);
}
static async instantiateVisitor(request: {visitor: string, visitorOptions?: Map},
preparedRecipes: Map,
recipeCursors: WeakMap,
p: any): Promise> {
const visitorName = request.visitor;
if (visitorName.startsWith("scan:")) {
const recipeKey = visitorName.substring("scan:".length);
const recipe = preparedRecipes.get(recipeKey) as ScanningRecipe;
if (!recipe) {
throw new Error(`No scanning recipe found for key: ${recipeKey}`);
}
// If we're transitioning from edit back to scan, this is a new cycle.
// Clear the cursor so a fresh accumulator is created.
if (recipePhases.get(recipe) === 'edit') {
recipeCursors.delete(recipe);
}
recipePhases.set(recipe, 'scan');
let cursor = recipeCursors.get(recipe);
if (!cursor) {
cursor = rootCursor();
recipeCursors.set(recipe, cursor);
}
const acc = recipe.accumulator(cursor, p);
return new class extends TreeVisitor {
// Delegate isAcceptable to the scanner visitor
// This ensures we only process source files the scanner can handle
async isAcceptable(sourceFile: SourceFile, ctx: ExecutionContext): Promise {
return (await recipe.scanner(acc)).isAcceptable(sourceFile, ctx);
}
protected async preVisit(tree: any, ctx: ExecutionContext): Promise {
await (await recipe.scanner(acc)).visit(tree, ctx);
this.stopAfterPreVisit();
return tree;
}
}
} else if (visitorName.startsWith("edit:")) {
const recipeKey = visitorName.substring("edit:".length);
const recipe = preparedRecipes.get(recipeKey) as Recipe;
if (!recipe) {
throw new Error(`No editing recipe found for key: ${recipeKey}`);
}
recipePhases.set(recipe, 'edit');
// For ScanningRecipe, we need to use the same cursor that was used during scanning
// to retrieve the accumulator that was stored there
if (recipe instanceof ScanningRecipe) {
let cursor = recipeCursors.get(recipe);
if (!cursor) {
cursor = rootCursor();
recipeCursors.set(recipe, cursor);
}
const acc = recipe.accumulator(cursor, p);
return recipe.editorWithData(acc);
}
return await recipe.editor();
} else {
const ctor = lookupVisitor(visitorName) ?? (globalThis as any)[visitorName];
if (!ctor) {
throw new Error(`Unknown visitor: ${visitorName}`);
}
return Reflect.construct(
ctor,
request.visitorOptions ? Array.from(request.visitorOptions.values()) : [])
}
}
}