diff --git a/package.json b/package.json index c6c0f371..49df6164 100644 --- a/package.json +++ b/package.json @@ -131,6 +131,10 @@ "command": "vscode-objectscript.ccs.getGlobalDocumentation", "when": "editorLangId =~ /^objectscript/ && vscode-objectscript.connectActive" }, + { + "command": "vscode-objectscript.ccs.locateTriggers", + "when": "editorLangId =~ /^objectscript/ && vscode-objectscript.connectActive" + }, { "command": "vscode-objectscript.subclass", "when": "editorLangId =~ /^objectscript/ && vscode-objectscript.connectActive" @@ -881,6 +885,16 @@ "command": "vscode-objectscript.ccs.followSourceAnalysisLink", "title": "Follow Source Analysis Link" }, + { + "category": "Consistem", + "command": "vscode-objectscript.ccs.locateTriggers", + "title": "Locate Triggers" + }, + { + "category": "Consistem", + "command": "vscode-objectscript.ccs.locateTriggers.openLocation", + "title": "Open Located Trigger" + }, { "category": "Consistem", "command": "vscode-objectscript.ccs.createItem", diff --git a/src/ccs/commands/locateTriggers.ts b/src/ccs/commands/locateTriggers.ts new file mode 100644 index 00000000..2d1544b6 --- /dev/null +++ b/src/ccs/commands/locateTriggers.ts @@ -0,0 +1,330 @@ +import * as path from "path"; +import * as vscode from "vscode"; + +import { DocumentContentProvider } from "../../providers/DocumentContentProvider"; +import { AtelierAPI } from "../../api"; +import { FILESYSTEM_SCHEMA } from "../../extension"; +import { handleError, outputChannel } from "../../utils"; +import { LocateTriggersClient, LocateTriggersPayload } from "../sourcecontrol/clients/locateTriggersClient"; +import { getUrisForDocument } from "../../utils/documentIndex"; +import { notIsfs } from "../../utils"; +import { getCcsSettings } from "../config/settings"; +import { createAbortSignal } from "../core/http"; +import { logDebug } from "../core/logging"; +import { ResolveDefinitionResponse } from "../core/types"; +import { SourceControlApi } from "../sourcecontrol/client"; +import { ROUTES } from "../sourcecontrol/routes"; +import { toVscodeLocation } from "../sourcecontrol/paths"; + +const TRIGGER_PATTERNS = [/GatilhoRegra\^%CSW1GATCUST/i, /GatilhoInterface\^%CSW1GATCUST/i]; + +const sharedClient = new LocateTriggersClient(); + +interface RoutineLocation { + routineName: string; + line: number; +} + +export async function locateTriggers(): Promise { + const editor = vscode.window.activeTextEditor; + + if (!editor) { + return; + } + + const routineName = path.basename(editor.document.fileName); + if (!routineName) { + void vscode.window.showErrorMessage("Routine name not available for localizar gatilhos."); + return; + } + + const selectedText = getSelectedOrCurrentLineText(editor); + const payload: LocateTriggersPayload = { routineName }; + + if (shouldSendSelectedText(selectedText)) { + payload.selectedText = escapeTriggerText(selectedText); + } + + try { + const { content, api } = await sharedClient.locate(editor.document, payload); + + if (!content || !content.trim()) { + void vscode.window.showInformationMessage("Localizar Gatilhos não retornou nenhum conteúdo."); + return; + } + + await renderContentToOutput(content, api.ns); + } catch (error) { + handleError(error, "Falha ao localizar gatilhos."); + } +} + +export async function openLocatedTriggerLocation(location?: RoutineLocation & { namespace?: string }): Promise { + if (!location?.routineName || !location.line) { + return; + } + + const namespace = location.namespace ?? new AtelierAPI().ns; + + if (!namespace) { + void vscode.window.showErrorMessage("Não foi possível determinar o namespace para abrir o gatilho."); + return; + } + + await openRoutineLocation(location.routineName, location.line, namespace); +} + +function getSelectedOrCurrentLineText(editor: vscode.TextEditor): string { + const { selection, document } = editor; + + if (!selection || selection.isEmpty) { + return document.lineAt(selection.active.line).text.trim(); + } + + return document.getText(selection).trim(); +} + +function shouldSendSelectedText(text: string): boolean { + return TRIGGER_PATTERNS.some((pattern) => pattern.test(text)); +} + +function escapeTriggerText(text: string): string { + return text.replace(/"/g, '""'); +} + +async function renderContentToOutput(content: string, namespace?: string): Promise { + const annotatedLines = await annotateRoutineLocations(content, namespace); + + annotatedLines.forEach((line) => outputChannel.appendLine(line)); + outputChannel.show(true); +} + +async function annotateRoutineLocations(content: string, namespace?: string): Promise { + const routineLineRegex = /^\s*([\w%][\w%.-]*\.[\w]+)\((\d+)\)/i; + const resolutionCache = new Map>(); + + const getResolvedUri = (routineName: string): Promise => { + const normalizedName = routineName.toLowerCase(); + + if (!resolutionCache.has(normalizedName)) { + resolutionCache.set(normalizedName, resolveWorkspaceRoutineUri(routineName)); + } + + return resolutionCache.get(normalizedName) ?? Promise.resolve(undefined); + }; + + return Promise.all( + content.split(/\r?\n/).map(async (line) => { + const match = routineLineRegex.exec(line); + + if (!match) { + return line; + } + + const [, routineName, lineStr] = match; + const lineNumber = Number.parseInt(lineStr, 10); + + if (!Number.isFinite(lineNumber)) { + return line; + } + + const resolvedUri = await getResolvedUri(routineName); + const baseLine = line.replace(/\s+$/, ""); + + if (resolvedUri) { + return `${baseLine} (${resolvedUri.toString()})`; + } + + return baseLine; + }) + ); +} + +async function openRoutineLocation(routineName: string, line: number, namespace: string): Promise { + const targetUri = await resolveRoutineUri(routineName, namespace); + + if (!targetUri) { + void vscode.window.showErrorMessage(`Não foi possível abrir a rotina ${routineName}.`); + return; + } + + const document = await vscode.workspace.openTextDocument(targetUri); + const editor = await vscode.window.showTextDocument(document, { preview: false }); + const targetLine = Math.max(line - 1, 0); + const position = new vscode.Position(targetLine, 0); + editor.selection = new vscode.Selection(position, position); + editor.revealRange(new vscode.Range(position, position), vscode.TextEditorRevealType.InCenter); +} + +async function getRoutineUriFromDefinition(routineName: string, namespace: string): Promise { + const api = new AtelierAPI(); + api.setNamespace(namespace); + + if (!api.active || !api.ns) { + return undefined; + } + + let sourceControlApi: SourceControlApi; + + try { + sourceControlApi = SourceControlApi.fromAtelierApi(api); + } catch (error) { + logDebug("Failed to create SourceControl API client for resolveDefinition", error); + return undefined; + } + + const { requestTimeout } = getCcsSettings(); + const tokenSource = new vscode.CancellationTokenSource(); + const { signal, dispose } = createAbortSignal(tokenSource.token); + const query = `^${routineName}`; + + try { + const response = await sourceControlApi.post( + ROUTES.resolveDefinition(api.ns), + { query }, + { + timeout: requestTimeout, + signal, + validateStatus: (status) => status >= 200 && status < 300, + } + ); + + return toVscodeLocation(response.data ?? {})?.uri; + } catch (error) { + logDebug("ResolveDefinition lookup for localizar gatilhos failed", error); + return undefined; + } finally { + dispose(); + tokenSource.dispose(); + } +} + +async function getRoutineUri(routineName: string, namespace: string): Promise { + const workspaceUri = await findWorkspaceRoutineUri(routineName); + + if (workspaceUri) { + return workspaceUri; + } + + const primaryUri = DocumentContentProvider.getUri(routineName, undefined, namespace); + + if (primaryUri) { + if (primaryUri.scheme === "file") { + try { + await vscode.workspace.fs.stat(primaryUri); + return primaryUri; + } catch (error) { + // Fall back to isfs when the routine isn't available locally. + } + } else { + return primaryUri; + } + } + + const fallbackWorkspaceUri = vscode.Uri.parse(`${FILESYSTEM_SCHEMA}://consistem:${namespace}/`); + return ( + DocumentContentProvider.getUri(routineName, undefined, namespace, undefined, fallbackWorkspaceUri, true) ?? + primaryUri + ); +} + +async function resolveRoutineUri(routineName: string, namespace?: string): Promise { + const workspaceUri = await findWorkspaceRoutineUri(routineName); + + if (workspaceUri) { + return workspaceUri; + } + + if (!namespace) { + return undefined; + } + + const definitionUri = await getRoutineUriFromDefinition(routineName, namespace); + + if (definitionUri) { + return definitionUri; + } + + return (await getRoutineUri(routineName, namespace)) ?? undefined; +} + +async function resolveWorkspaceRoutineUri(routineName: string): Promise { + const workspaceUri = await findWorkspaceRoutineUri(routineName); + + if (!workspaceUri) { + return undefined; + } + + try { + await vscode.workspace.fs.stat(workspaceUri); + return workspaceUri; + } catch (error) { + return undefined; + } +} + +async function findWorkspaceRoutineUri(routineName: string): Promise { + const workspaces = vscode.workspace.workspaceFolders ?? []; + const candidates: vscode.Uri[] = []; + const dedupe = new Set(); + const preferredRoot = normalizeFsPath(path.normalize("C:/workspacecsw/projetos/COMP-7.0/xcustom/")); + + const addCandidate = (uri: vscode.Uri): void => { + if (!notIsfs(uri) || dedupe.has(uri.toString())) { + return; + } + + candidates.push(uri); + dedupe.add(uri.toString()); + }; + + for (const workspace of workspaces) { + if (!notIsfs(workspace.uri)) { + continue; + } + + for (const uri of getUrisForDocument(routineName, workspace)) { + addCandidate(uri); + } + } + + const allMatches = await vscode.workspace.findFiles(`**/${routineName}`); + const preferredMatches: vscode.Uri[] = []; + + for (const uri of allMatches) { + if (!notIsfs(uri)) { + continue; + } + + const normalizedPath = normalizeFsPath(uri.fsPath); + + if (normalizedPath.includes(preferredRoot)) { + preferredMatches.push(uri); + } + + addCandidate(uri); + } + + if (preferredMatches.length) { + return preferredMatches[0]; + } + + if (!candidates.length) { + return undefined; + } + + const preferredSegment = `${path.sep}xcustom${path.sep}`; + + return ( + candidates.find((uri) => { + const lowerPath = normalizeFsPath(uri.fsPath); + return ( + lowerPath.includes(preferredSegment) || lowerPath.includes("/xcustom/") || lowerPath.includes("\\xcustom\\") + ); + }) ?? candidates[0] + ); +} + +function normalizeFsPath(p: string): string { + return p.replace(/\\/g, "/").toLowerCase(); +} diff --git a/src/ccs/core/http.ts b/src/ccs/core/http.ts index 50254aea..c08bfa42 100644 --- a/src/ccs/core/http.ts +++ b/src/ccs/core/http.ts @@ -70,8 +70,13 @@ function resolveFullUrl(client: AxiosInstance, config: AxiosRequestConfig | Inte return `${base}${url}`; } -export function createAbortSignal(token: vscode.CancellationToken): { signal: AbortSignal; dispose: () => void } { +export function createAbortSignal(token?: vscode.CancellationToken): { signal: AbortSignal; dispose: () => void } { const controller = new AbortController(); + + if (!token) { + return { signal: controller.signal, dispose: () => undefined }; + } + const subscription = token.onCancellationRequested(() => controller.abort()); return { diff --git a/src/ccs/index.ts b/src/ccs/index.ts index 9d7ff0ef..f17e0a93 100644 --- a/src/ccs/index.ts +++ b/src/ccs/index.ts @@ -16,6 +16,7 @@ export { export { goToDefinitionLocalFirst } from "./commands/goToDefinitionLocalFirst"; export { followDefinitionLink } from "./commands/followDefinitionLink"; export { jumpToTagAndOffsetCrossEntity } from "./commands/jumpToTagOffsetCrossEntity"; +export { locateTriggers, openLocatedTriggerLocation } from "./commands/locateTriggers"; export { PrioritizedDefinitionProvider } from "./providers/PrioritizedDefinitionProvider"; export { DefinitionDocumentLinkProvider, diff --git a/src/ccs/sourcecontrol/clients/locateTriggersClient.ts b/src/ccs/sourcecontrol/clients/locateTriggersClient.ts new file mode 100644 index 00000000..d40f5749 --- /dev/null +++ b/src/ccs/sourcecontrol/clients/locateTriggersClient.ts @@ -0,0 +1,73 @@ +import * as vscode from "vscode"; + +import { AtelierAPI } from "../../../api"; +import { getCcsSettings } from "../../config/settings"; +import { createAbortSignal } from "../../core/http"; +import { logDebug } from "../../core/logging"; +import { SourceControlApi } from "../client"; +import { ROUTES } from "../routes"; + +export interface LocateTriggersPayload { + routineName: string; + selectedText?: string; +} + +export class LocateTriggersClient { + private readonly apiFactory: (api: AtelierAPI) => SourceControlApi; + + public constructor(apiFactory: (api: AtelierAPI) => SourceControlApi = SourceControlApi.fromAtelierApi) { + this.apiFactory = apiFactory; + } + + public async locate( + document: vscode.TextDocument, + payload: LocateTriggersPayload, + token?: vscode.CancellationToken + ): Promise<{ content: string; api: AtelierAPI }> { + const api = this.resolveApi(document); + + let sourceControlApi: SourceControlApi; + try { + sourceControlApi = this.apiFactory(api); + } catch (error) { + logDebug("Failed to create SourceControl API client for localizar gatilhos", error); + throw error; + } + + const { requestTimeout } = getCcsSettings(); + const { signal, dispose } = createAbortSignal(token); + + try { + const response = await sourceControlApi.post(ROUTES.locateTriggers(api.ns), payload, { + timeout: requestTimeout, + signal, + responseType: "text", + transformResponse: (data) => data, + validateStatus: (status) => status >= 200 && status < 300, + }); + + return { content: typeof response.data === "string" ? response.data : "", api }; + } catch (error) { + logDebug("Localizar gatilhos request failed", error); + throw error; + } finally { + dispose(); + } + } + + private resolveApi(document: vscode.TextDocument): AtelierAPI { + let api = new AtelierAPI(document.uri); + + if (!api.active || !api.ns) { + const fallbackApi = new AtelierAPI(); + + if (fallbackApi.active && fallbackApi.ns) { + api = fallbackApi; + } else { + throw new Error("No active namespace for localizar gatilhos."); + } + } + + return api; + } +} diff --git a/src/ccs/sourcecontrol/routes.ts b/src/ccs/sourcecontrol/routes.ts index 1611ed77..b6d4504a 100644 --- a/src/ccs/sourcecontrol/routes.ts +++ b/src/ccs/sourcecontrol/routes.ts @@ -6,6 +6,7 @@ export const ROUTES = { resolveDefinition: (namespace: string) => `/namespaces/${encodeURIComponent(namespace)}/resolveDefinition`, createItem: (namespace: string) => `/namespaces/${encodeURIComponent(namespace)}/createItem`, runUnitTests: (namespace: string) => `/namespaces/${encodeURIComponent(namespace)}/unitTests/runUnitTests`, + locateTriggers: (namespace: string) => `/namespaces/${encodeURIComponent(namespace)}/localizarGatilhos`, } as const; export type RouteKey = keyof typeof ROUTES; diff --git a/src/extension.ts b/src/extension.ts index 2ee311c6..cf762f48 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -176,6 +176,8 @@ import { jumpToTagAndOffsetCrossEntity, resolveContextExpression, showGlobalDocumentation, + locateTriggers, + openLocatedTriggerLocation, } from "./ccs"; const packageJson = vscode.extensions.getExtension(extensionId).packageJSON; @@ -1431,6 +1433,14 @@ export async function activate(context: vscode.ExtensionContext): Promise { sendCommandTelemetryEvent("getGlobalDocumentation"); void showGlobalDocumentation(); }), + vscode.commands.registerCommand("vscode-objectscript.ccs.locateTriggers", () => { + sendCommandTelemetryEvent("locateTriggers"); + void locateTriggers(); + }), + vscode.commands.registerCommand("vscode-objectscript.ccs.locateTriggers.openLocation", (location) => { + sendCommandTelemetryEvent("locateTriggers.openLocation"); + void openLocatedTriggerLocation(location); + }), vscode.commands.registerCommand("vscode-objectscript.serverCommands.sourceControl", (uri?: vscode.Uri) => { sendCommandTelemetryEvent("serverCommands.sourceControl"); mainSourceControlMenu(uri);