From 1a27f60eba54c57cb6cb981a5241090679241bb2 Mon Sep 17 00:00:00 2001 From: LeoAnders Date: Thu, 25 Sep 2025 22:10:33 -0300 Subject: [PATCH 1/5] revert: remove cross-workspace definition lookup (45b7b82) --- README.md | 28 +----- package.json | 8 -- src/providers/DocumentContentProvider.ts | 85 +++++-------------- src/test/suite/extension.test.ts | 82 ++---------------- .../multi-root/client/.vscode/settings.json | 12 --- .../client/src/MultiRoot/Caller.cls | 10 --- .../multi-root/shared/.vscode/settings.json | 9 -- .../shared/src/MultiRoot/Shared.cls | 9 -- test-fixtures/test.code-workspace | 10 +-- 9 files changed, 32 insertions(+), 221 deletions(-) delete mode 100644 test-fixtures/multi-root/client/.vscode/settings.json delete mode 100644 test-fixtures/multi-root/client/src/MultiRoot/Caller.cls delete mode 100644 test-fixtures/multi-root/shared/.vscode/settings.json delete mode 100644 test-fixtures/multi-root/shared/src/MultiRoot/Shared.cls diff --git a/README.md b/README.md index 3247a3c3..b30f870d 100644 --- a/README.md +++ b/README.md @@ -48,51 +48,29 @@ Open VS Code. Go to Extensions view (/Ctrl+Shift **Implementation developed and maintained by Consistem Sistemas** - -When working in a multi-root workspace, the extension normally searches the current workspace folder (and any sibling folders connected to the same namespace) for local copies of ObjectScript code before requesting the server version. If you keep shared source code in other workspace folders with different connection settings, set the `objectscript.export.searchOtherWorkspaceFolders` array in the consuming folder's settings so those folders are considered first. Use workspace-folder names, or specify `"*"` to search every non-`isfs` folder. - -```json -{ - "objectscript.export": { - "folder": "src", - "searchOtherWorkspaceFolders": ["shared"] - } -} -``` - -With this setting enabled, features such as Go to Definition resolve to the first matching local file across the configured workspace folders before falling back to the server copy. - ## Notes - Connection-related output appears in the 'Output' view while switched to the 'ObjectScript' channel using the drop-down menu on the view titlebar. diff --git a/package.json b/package.json index aae9a5b1..490658ac 100644 --- a/package.json +++ b/package.json @@ -1429,14 +1429,6 @@ }, "additionalProperties": false }, - "searchOtherWorkspaceFolders": { - "markdownDescription": "Additional workspace folders to search for client-side sources when resolving ObjectScript documents. Specify `\"*\"` to search all non-isfs workspace folders in the current multi-root workspace before falling back to the server.", - "type": "array", - "items": { - "type": "string" - }, - "default": [] - }, "atelier": { "description": "Export source code as Atelier did it, with packages as subfolders. This setting only affects classes, routines, include files and DFI files.", "type": "boolean" diff --git a/src/providers/DocumentContentProvider.ts b/src/providers/DocumentContentProvider.ts index 24857303..c9eae4a4 100644 --- a/src/providers/DocumentContentProvider.ts +++ b/src/providers/DocumentContentProvider.ts @@ -165,73 +165,30 @@ export class DocumentContentProvider implements vscode.TextDocumentContentProvid }); } } else { - const conn = config("conn", workspaceFolder) ?? {}; - const exportConfig = - workspaceFolder && workspaceFolder !== "" - ? (config("export", workspaceFolder) as { searchOtherWorkspaceFolders?: string[] }) - : undefined; - const searchOtherWorkspaceFolders = Array.isArray(exportConfig?.searchOtherWorkspaceFolders) - ? exportConfig.searchOtherWorkspaceFolders - .map((value) => (typeof value === "string" ? value.trim() : "")) - .filter((value) => value.length > 0) - : []; - const includeAllFolders = searchOtherWorkspaceFolders.includes("*"); - const explicitAdditionalFolders = new Set( - searchOtherWorkspaceFolders.filter((value) => value !== "*").map((value) => value.toLowerCase()) - ); + const conn = config("conn", workspaceFolder); if (!forceServerCopy) { - const tryLocalUri = (folderName: string, allowNamespaceMismatch: boolean): vscode.Uri => { - const localFile = this.findLocalUri(name, folderName); - if (!localFile) return; - if (!allowNamespaceMismatch && namespace) { - const folderConn = config("conn", folderName) ?? {}; - if (folderConn.ns && namespace !== folderConn.ns) { - return; - } - } - return localFile; - }; - // Look for the document in the local file system - const primaryLocal = tryLocalUri(workspaceFolder, false); - if (primaryLocal) { - return primaryLocal; - } - - // Check any other eligible local folders in this workspace if it's a multi-root workspace - const wFolders = vscode.workspace.workspaceFolders; - if (wFolders && wFolders.length > 1 && workspaceFolder) { - const candidates: { folder: vscode.WorkspaceFolder; allowNamespaceMismatch: boolean }[] = []; - const seen = new Set(); - const addCandidate = (folder: vscode.WorkspaceFolder, allowNamespaceMismatch: boolean): void => { - if (!notIsfs(folder.uri)) return; - if (folder.name === workspaceFolder) return; - if (seen.has(folder.name)) return; - candidates.push({ folder, allowNamespaceMismatch }); - seen.add(folder.name); - }; - - for (const wFolder of wFolders) { - if (wFolder.name === workspaceFolder) continue; - const wFolderConn = config("conn", wFolder.name) ?? {}; - if (compareConns(conn, wFolderConn) && (!namespace || namespace === wFolderConn.ns)) { - addCandidate(wFolder, false); - } - } - - if (includeAllFolders || explicitAdditionalFolders.size > 0) { + const localFile = this.findLocalUri(name, workspaceFolder); + if (localFile && (!namespace || namespace === conn.ns)) { + // Exists as a local file and we aren't viewing a different namespace on the same server, + // so return a uri that will open the local file. + return localFile; + } else { + // The local file doesn't exist in this folder, so check any other + // local folders in this workspace if it's a multi-root workspace + const wFolders = vscode.workspace.workspaceFolders; + if (wFolders && wFolders.length > 1) { + // This is a multi-root workspace for (const wFolder of wFolders) { - if (wFolder.name === workspaceFolder) continue; - const shouldInclude = includeAllFolders || explicitAdditionalFolders.has(wFolder.name.toLowerCase()); - if (!shouldInclude) continue; - addCandidate(wFolder, true); - } - } - - for (const candidate of candidates) { - const candidateLocal = tryLocalUri(candidate.folder.name, candidate.allowNamespaceMismatch); - if (candidateLocal) { - return candidateLocal; + if (notIsfs(wFolder.uri) && wFolder.name != workspaceFolder) { + // This isn't the folder that we checked originally + const wFolderConn = config("conn", wFolder.name); + if (compareConns(conn, wFolderConn) && (!namespace || namespace === wFolderConn.ns)) { + // This folder is connected to the same server:ns combination as the original folder + const wFolderFile = this.findLocalUri(name, wFolder.name); + if (wFolderFile) return wFolderFile; + } + } } } } diff --git a/src/test/suite/extension.test.ts b/src/test/suite/extension.test.ts index 05016507..99be046b 100644 --- a/src/test/suite/extension.test.ts +++ b/src/test/suite/extension.test.ts @@ -1,25 +1,11 @@ import * as assert from "assert"; import { before } from "mocha"; -import * as path from "path"; // You can import and use all API from the 'vscode' module // as well as import your extension to test it import * as vscode from "vscode"; -import { extensionId, smExtensionId, OBJECTSCRIPT_FILE_SCHEMA } from "../../extension"; -import { getUrisForDocument } from "../../utils/documentIndex"; - -async function waitForIndexedDocument(documentName: string, workspaceFolderName: string): Promise { - const workspaceFolder = vscode.workspace.workspaceFolders?.find((wf) => wf.name === workspaceFolderName); - assert.ok(workspaceFolder, `Workspace folder '${workspaceFolderName}' was not found.`); - const start = Date.now(); - while (Date.now() - start < 10000) { - if (getUrisForDocument(documentName, workspaceFolder).length > 0) { - return; - } - await new Promise((resolve) => setTimeout(resolve, 100)); - } - assert.fail(`Timed out waiting for '${documentName}' to be indexed in workspace folder '${workspaceFolderName}'.`); -} +import { window, extensions } from "vscode"; +import { extensionId, smExtensionId } from "../../extension"; async function waitForCondition(predicate: () => boolean, timeoutMs = 1000, message?: string): Promise { const start = Date.now(); @@ -32,23 +18,17 @@ async function waitForCondition(predicate: () => boolean, timeoutMs = 1000, mess assert.fail(message ?? "Timed out waiting for condition"); } -function getDefinitionTargets(definitions: (vscode.Location | vscode.DefinitionLink)[]): vscode.Uri[] { - return definitions - .map((definition) => ("targetUri" in definition ? definition.targetUri : definition.uri)) - .filter((uri): uri is vscode.Uri => !!uri); -} - suite("Extension Test Suite", () => { suiteSetup(async function () { // make sure extension is activated - const serverManager = vscode.extensions.getExtension(smExtensionId); + const serverManager = extensions.getExtension(smExtensionId); await serverManager?.activate(); - const ext = vscode.extensions.getExtension(extensionId); + const ext = extensions.getExtension(extensionId); await ext?.activate(); }); before(() => { - vscode.window.showInformationMessage("Start all tests."); + window.showInformationMessage("Start all tests."); }); test("Sample test", () => { @@ -107,56 +87,4 @@ suite("Extension Test Suite", () => { await vscode.commands.executeCommand("workbench.action.closeActiveEditor"); } }); - test("Go to Definition resolves to sibling workspace folder", async function () { - this.timeout(10000); - await waitForIndexedDocument("MultiRoot.Shared.cls", "shared"); - const clientFolder = vscode.workspace.workspaceFolders?.find((wf) => wf.name === "client"); - assert.ok(clientFolder, "Client workspace folder not available."); - const callerUri = vscode.Uri.joinPath(clientFolder.uri, "src", "MultiRoot", "Caller.cls"); - const document = await vscode.workspace.openTextDocument(callerUri); - await vscode.window.showTextDocument(document); - - const target = "MultiRoot.Shared"; - const sharedOffset = document.getText().indexOf(target); - assert.notStrictEqual(sharedOffset, -1, "Shared class reference not found in Caller.cls"); - const position = document.positionAt(sharedOffset + target.indexOf("Shared") + 1); - const definitions = (await vscode.commands.executeCommand( - "vscode.executeDefinitionProvider", - callerUri, - position - )) as (vscode.Location | vscode.DefinitionLink)[]; - assert.ok(definitions?.length, "Expected at least one definition result"); - const targetUris = getDefinitionTargets(definitions); - const sharedTargetSuffix = path.join("shared", "src", "MultiRoot", "Shared.cls"); - assert.ok( - targetUris.some((uri) => uri.scheme === "file" && uri.fsPath.endsWith(sharedTargetSuffix)), - "Expected Go to Definition to resolve to the shared workspace folder" - ); - }); - - test("Go to Definition falls back to server URI when local copy missing", async function () { - this.timeout(10000); - await waitForIndexedDocument("MultiRoot.Shared.cls", "shared"); - const clientFolder = vscode.workspace.workspaceFolders?.find((wf) => wf.name === "client"); - assert.ok(clientFolder, "Client workspace folder not available."); - const callerUri = vscode.Uri.joinPath(clientFolder.uri, "src", "MultiRoot", "Caller.cls"); - const document = await vscode.workspace.openTextDocument(callerUri); - await vscode.window.showTextDocument(document); - - const target = "MultiRoot.ServerOnly"; - const offset = document.getText().indexOf(target); - assert.notStrictEqual(offset, -1, "Server-only class reference not found in Caller.cls"); - const position = document.positionAt(offset + target.indexOf("ServerOnly") + 1); - const definitions = (await vscode.commands.executeCommand( - "vscode.executeDefinitionProvider", - callerUri, - position - )) as (vscode.Location | vscode.DefinitionLink)[]; - assert.ok(definitions?.length, "Expected definition result when resolving missing class"); - const targetUris = getDefinitionTargets(definitions); - assert.ok( - targetUris.some((uri) => uri.scheme === OBJECTSCRIPT_FILE_SCHEMA), - "Expected Go to Definition to return a server URI when no local copy exists" - ); - }); }); diff --git a/test-fixtures/multi-root/client/.vscode/settings.json b/test-fixtures/multi-root/client/.vscode/settings.json deleted file mode 100644 index c3b581df..00000000 --- a/test-fixtures/multi-root/client/.vscode/settings.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "objectscript.conn": { - "active": true, - "ns": "USER" - }, - "objectscript.export": { - "folder": "src", - "searchOtherWorkspaceFolders": [ - "shared" - ] - } -} diff --git a/test-fixtures/multi-root/client/src/MultiRoot/Caller.cls b/test-fixtures/multi-root/client/src/MultiRoot/Caller.cls deleted file mode 100644 index 79cad05b..00000000 --- a/test-fixtures/multi-root/client/src/MultiRoot/Caller.cls +++ /dev/null @@ -1,10 +0,0 @@ -Class MultiRoot.Caller Extends %RegisteredObject -{ - -ClassMethod Test() -{ - Do ##class(MultiRoot.Shared).Ping() - Do ##class(MultiRoot.ServerOnly).Ping() -} - -} diff --git a/test-fixtures/multi-root/shared/.vscode/settings.json b/test-fixtures/multi-root/shared/.vscode/settings.json deleted file mode 100644 index 4753cef3..00000000 --- a/test-fixtures/multi-root/shared/.vscode/settings.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "objectscript.conn": { - "active": false, - "ns": "SAMPLES" - }, - "objectscript.export": { - "folder": "src" - } -} diff --git a/test-fixtures/multi-root/shared/src/MultiRoot/Shared.cls b/test-fixtures/multi-root/shared/src/MultiRoot/Shared.cls deleted file mode 100644 index d176babf..00000000 --- a/test-fixtures/multi-root/shared/src/MultiRoot/Shared.cls +++ /dev/null @@ -1,9 +0,0 @@ -Class MultiRoot.Shared Extends %RegisteredObject -{ - -ClassMethod Ping() -{ - Quit -} - -} diff --git a/test-fixtures/test.code-workspace b/test-fixtures/test.code-workspace index e49cb155..ba637669 100644 --- a/test-fixtures/test.code-workspace +++ b/test-fixtures/test.code-workspace @@ -1,19 +1,15 @@ { "folders": [ { - "name": "client", - "path": "multi-root/client" + "path": "." }, - { - "name": "shared", - "path": "multi-root/shared" - } ], "settings": { "objectscript.conn": { "active": false }, "objectscript.ignoreInstallServerManager": true, - "intersystems.servers": {} + "intersystems.servers": { + } } } From 482511d45ccc7ece5f941e0809145b1963ab06d4 Mon Sep 17 00:00:00 2001 From: LeoAnders Date: Sun, 28 Sep 2025 21:34:38 -0300 Subject: [PATCH 2/5] feat: add API-based Go to Definition command and integrate with keybinding - Introduced DefinitionResolverClient for REST API resolution - Implemented definitionLookup feature (extractQuery + lookup) for robust query handling - Added `PrioritizedDefinitionProvider` to prefer CCS resolver before fallback - Implemented new command vscode-objectscript.ccs.goToDefinition with API-first fallback - Updated package.json to bind F12 and menus to the new command for ObjectScript files - Registered new command in extension.ts and integrated telemetry - New goToDefinitionLocalFirst command integrates CCS API before native definition --- package.json | 29 ++++++ src/api/ccs/definitionResolver.ts | 68 ++++++++++++++ src/ccs/commands/goToDefinitionLocalFirst.ts | 26 ++++++ src/ccs/core/types.ts | 7 ++ .../features/definitionLookup/extractQuery.ts | 88 +++++++++++++++++++ src/ccs/features/definitionLookup/lookup.ts | 82 +++++++++++++++++ src/ccs/index.ts | 5 ++ .../PrioritizedDefinitionProvider.ts | 34 +++++++ .../clients/resolveDefinitionClient.ts | 80 +++++++++++++++++ src/ccs/sourcecontrol/paths.ts | 36 ++++++++ src/ccs/sourcecontrol/routes.ts | 1 + src/extension.ts | 15 +++- 12 files changed, 469 insertions(+), 2 deletions(-) create mode 100644 src/api/ccs/definitionResolver.ts create mode 100644 src/ccs/commands/goToDefinitionLocalFirst.ts create mode 100644 src/ccs/features/definitionLookup/extractQuery.ts create mode 100644 src/ccs/features/definitionLookup/lookup.ts create mode 100644 src/ccs/providers/PrioritizedDefinitionProvider.ts create mode 100644 src/ccs/sourcecontrol/clients/resolveDefinitionClient.ts create mode 100644 src/ccs/sourcecontrol/paths.ts diff --git a/package.json b/package.json index 490658ac..eb6233b9 100644 --- a/package.json +++ b/package.json @@ -478,6 +478,15 @@ } ], "editor/context": [ + { + "command": "vscode-objectscript.ccs.goToDefinition", + "group": "navigation@0", + "when": "editorTextFocus && editorLangId =~ /^objectscript/" + }, + { + "command": "-editor.action.revealDefinition", + "when": "editorLangId =~ /^objectscript/" + }, { "command": "vscode-objectscript.viewOthers", "when": "vscode-objectscript.connectActive", @@ -525,6 +534,16 @@ } ], "editor/title": [ + { + "command": "vscode-objectscript.ccs.goToDefinition", + "group": "navigation@0", + "when": "editorTextFocus && editorLangId =~ /^objectscript/" + }, + { + "command": "-editor.action.revealDefinition", + "group": "navigation@0", + "when": "editorLangId =~ /^objectscript/" + }, { "command": "vscode-objectscript.serverCommands.sourceControl", "group": "navigation@1", @@ -850,6 +869,11 @@ "title": "Show Global Documentation", "enablement": "vscode-objectscript.connectActive" }, + { + "category": "ObjectScript", + "command": "vscode-objectscript.ccs.goToDefinition", + "title": "Go to Definition" + }, { "category": "ObjectScript", "command": "vscode-objectscript.compile", @@ -1218,6 +1242,11 @@ } ], "keybindings": [ + { + "command": "vscode-objectscript.ccs.goToDefinition", + "key": "F12", + "when": "editorTextFocus && editorLangId =~ /^objectscript/" + }, { "command": "vscode-objectscript.compile", "key": "Ctrl+F7", diff --git a/src/api/ccs/definitionResolver.ts b/src/api/ccs/definitionResolver.ts new file mode 100644 index 00000000..4577bf94 --- /dev/null +++ b/src/api/ccs/definitionResolver.ts @@ -0,0 +1,68 @@ +import axios, { AxiosInstance } from "axios"; +import * as vscode from "vscode"; +import { AtelierAPI } from "../"; + +interface ResolveDefinitionResponse { + uri?: string; + line?: number; +} + +export class DefinitionResolverApiClient { + private readonly axiosInstance: AxiosInstance; + + public constructor(axiosInstance: AxiosInstance = axios) { + this.axiosInstance = axiosInstance; + } + + public async resolve( + document: vscode.TextDocument, + query: string, + token: vscode.CancellationToken, + timeout = 500 + ): Promise { + const api = new AtelierAPI(document.uri); + const { https, host, port, pathPrefix, username, password } = api.config; + const ns = api.ns; + + if (!api.active || !ns || !host || !username || !password || !port) { + return undefined; + } + + const normalizedPrefix = pathPrefix ? (pathPrefix.startsWith("/") ? pathPrefix : `/${pathPrefix}`) : ""; + const trimmedPrefix = normalizedPrefix.endsWith("/") ? normalizedPrefix.slice(0, -1) : normalizedPrefix; + const baseUrl = `${https ? "https" : "http"}://${host}:${port}${trimmedPrefix}`; + const requestUrl = `${baseUrl}/api/sourcecontrol/vscode/namespaces/${encodeURIComponent(ns)}/resolveDefinition`; + + const controller = new AbortController(); + const disposeCancellation = token.onCancellationRequested(() => controller.abort()); + + try { + const response = await this.axiosInstance.post( + requestUrl, + { query }, + { + auth: { username, password }, + headers: { "Content-Type": "application/json" }, + timeout, + signal: controller.signal, + validateStatus: (status) => status >= 200 && status < 300, + } + ); + + const data = response.data; + if (data && typeof data.uri === "string" && data.uri.length && typeof data.line === "number") { + const zeroBasedLine = Math.max(0, Math.floor(data.line) - 1); + const targetUri = vscode.Uri.file(data.uri.replace(/\\/g, "/")); + return new vscode.Location(targetUri, new vscode.Position(zeroBasedLine, 0)); + } + } catch (error) { + if (!axios.isCancel(error)) { + // Swallow any errors to allow the native provider to handle the request. + } + } finally { + disposeCancellation.dispose(); + } + + return undefined; + } +} diff --git a/src/ccs/commands/goToDefinitionLocalFirst.ts b/src/ccs/commands/goToDefinitionLocalFirst.ts new file mode 100644 index 00000000..31d48b38 --- /dev/null +++ b/src/ccs/commands/goToDefinitionLocalFirst.ts @@ -0,0 +1,26 @@ +import * as vscode from "vscode"; + +import { lookupCcsDefinition } from "../features/definitionLookup/lookup"; + +export async function goToDefinitionLocalFirst(): Promise { + const editor = vscode.window.activeTextEditor; + if (!editor) { + return; + } + + const { document, selection } = editor; + const position = selection.active; + const tokenSource = new vscode.CancellationTokenSource(); + + try { + const location = await lookupCcsDefinition(document, position, tokenSource.token); + if (location) { + await vscode.window.showTextDocument(location.uri, { selection: location.range }); + return; + } + } finally { + tokenSource.dispose(); + } + + await vscode.commands.executeCommand("editor.action.revealDefinition"); +} diff --git a/src/ccs/core/types.ts b/src/ccs/core/types.ts index 6c821c53..311b15aa 100644 --- a/src/ccs/core/types.ts +++ b/src/ccs/core/types.ts @@ -1,3 +1,10 @@ +export interface LocationJSON { + uri?: string; + line?: number; +} + +export interface ResolveDefinitionResponse extends LocationJSON {} + export interface ResolveContextExpressionResponse { status?: string; textExpression?: string; diff --git a/src/ccs/features/definitionLookup/extractQuery.ts b/src/ccs/features/definitionLookup/extractQuery.ts new file mode 100644 index 00000000..70f9ca93 --- /dev/null +++ b/src/ccs/features/definitionLookup/extractQuery.ts @@ -0,0 +1,88 @@ +import * as vscode from "vscode"; + +export type QueryKind = "labelRoutine" | "routine" | "macro" | "class"; + +export interface QueryMatch { + query: string; + normalizedQuery: string; + kind: QueryKind; + symbolName?: string; +} + +export function extractDefinitionQuery( + document: vscode.TextDocument, + position: vscode.Position +): QueryMatch | undefined { + const lineText = document.lineAt(position.line).text; + const charIndex = position.character; + + const labelRoutine = findMatch(lineText, /\$\$([%A-Za-z][\w]*)\^([%A-Za-z][\w]*(?:\.[%A-Za-z][\w]*)*)/g, charIndex); + if (labelRoutine) { + const [, , routineName] = labelRoutine.match; + const normalizedQuery = labelRoutine.text.replace(/^\$\$+/, ""); + return { + query: labelRoutine.text, + normalizedQuery, + kind: "labelRoutine", + symbolName: routineName, + }; + } + + const caretRoutine = findMatch(lineText, /\^(%?[A-Za-z][\w]*(?:\.[%A-Za-z][\w]*)*)/g, charIndex); + if (caretRoutine) { + const [, routineName] = caretRoutine.match; + return { + query: caretRoutine.text, + normalizedQuery: caretRoutine.text, + kind: "routine", + symbolName: routineName, + }; + } + + const macro = findMatch(lineText, /\${3}([%A-Za-z][%A-Za-z0-9_]*)/g, charIndex); + if (macro) { + const [, macroName] = macro.match; + return { + query: macro.text, + normalizedQuery: macro.text, + kind: "macro", + symbolName: macroName, + }; + } + + const classRef = findMatch( + lineText, + /##class\s*\(\s*([%A-Za-z][\w]*(?:\.[%A-Za-z][\w]*)*)\s*\)(?:\s*\.\s*([%A-Za-z][\w]*))?/gi, + charIndex + ); + if (classRef) { + const [, className, methodName] = classRef.match; + const normalizedQuery = methodName ? `##class(${className}).${methodName}` : `##class(${className})`; + return { + query: classRef.text, + normalizedQuery, + kind: "class", + symbolName: className, + }; + } + + return undefined; +} + +interface MatchResult { + text: string; + match: RegExpExecArray; +} + +function findMatch(line: string, regex: RegExp, character: number): MatchResult | undefined { + regex.lastIndex = 0; + let match: RegExpExecArray | null = null; + while ((match = regex.exec(line)) !== null) { + const start = match.index; + const end = start + match[0].length; + if (character >= start && character <= end) { + return { text: match[0], match }; + } + } + return undefined; +} diff --git a/src/ccs/features/definitionLookup/lookup.ts b/src/ccs/features/definitionLookup/lookup.ts new file mode 100644 index 00000000..7513bf58 --- /dev/null +++ b/src/ccs/features/definitionLookup/lookup.ts @@ -0,0 +1,82 @@ +import * as vscode from "vscode"; + +import { ResolveDefinitionClient } from "../../sourcecontrol/clients/resolveDefinitionClient"; +import { currentFile, CurrentTextFile } from "../../../utils"; +import { extractDefinitionQuery, QueryMatch } from "./extractQuery"; + +const sharedClient = new ResolveDefinitionClient(); + +export interface LookupOptions { + client?: ResolveDefinitionClient; + onNoResult?: (details: { query: string; originalQuery?: string }) => void; +} + +export async function lookupCcsDefinition( + document: vscode.TextDocument, + position: vscode.Position, + token: vscode.CancellationToken, + options: LookupOptions = {} +): Promise { + const match = extractDefinitionQuery(document, position); + if (!match) { + return undefined; + } + + if (!shouldUseExternalResolver(document, match)) { + return undefined; + } + + const client = options.client ?? sharedClient; + const location = await client.resolve(document, match.normalizedQuery, token); + if (!location) { + options.onNoResult?.({ query: match.normalizedQuery, originalQuery: match.query }); + } + return location; +} + +function shouldUseExternalResolver(document: vscode.TextDocument, match: QueryMatch): boolean { + const current = currentFile(document); + if (!current) { + return true; + } + + switch (match.kind) { + case "macro": + return !hasLocalMacroDefinition(document, match.symbolName); + case "class": + return !isCurrentClass(current, match.symbolName); + case "labelRoutine": + case "routine": + return !isCurrentRoutine(current, match.symbolName); + default: + return true; + } +} + +function hasLocalMacroDefinition(document: vscode.TextDocument, macroName?: string): boolean { + if (!macroName) { + return false; + } + const regex = new RegExp(`^[\t ]*#def(?:ine|1arg)\\s+${macroName}\\b`, "mi"); + return regex.test(document.getText()); +} + +function isCurrentClass(current: CurrentTextFile, target?: string): boolean { + if (!target || !current.name.toLowerCase().endsWith(".cls")) { + return false; + } + const currentClassName = current.name.slice(0, -4); + return currentClassName.toLowerCase() === target.toLowerCase(); +} + +function isCurrentRoutine(current: CurrentTextFile, target?: string): boolean { + if (!target) { + return false; + } + const routineMatch = current.name.match(/^(.*)\.(mac|int|inc)$/i); + if (!routineMatch) { + return false; + } + const [, routineName] = routineMatch; + return routineName.toLowerCase() === target.toLowerCase(); +} diff --git a/src/ccs/index.ts b/src/ccs/index.ts index 0ce8af2e..29874889 100644 --- a/src/ccs/index.ts +++ b/src/ccs/index.ts @@ -5,3 +5,8 @@ export { resolveContextExpression } from "./commands/contextHelp"; export { showGlobalDocumentation } from "./commands/globalDocumentation"; export { ContextExpressionClient } from "./sourcecontrol/clients/contextExpressionClient"; export { GlobalDocumentationClient } from "./sourcecontrol/clients/globalDocumentationClient"; +export { ResolveDefinitionClient } from "./sourcecontrol/clients/resolveDefinitionClient"; +export { lookupCcsDefinition, type LookupOptions } from "./features/definitionLookup/lookup"; +export { extractDefinitionQuery, type QueryMatch, type QueryKind } from "./features/definitionLookup/extractQuery"; +export { goToDefinitionLocalFirst } from "./commands/goToDefinitionLocalFirst"; +export { PrioritizedDefinitionProvider } from "./providers/PrioritizedDefinitionProvider"; diff --git a/src/ccs/providers/PrioritizedDefinitionProvider.ts b/src/ccs/providers/PrioritizedDefinitionProvider.ts new file mode 100644 index 00000000..fdffc809 --- /dev/null +++ b/src/ccs/providers/PrioritizedDefinitionProvider.ts @@ -0,0 +1,34 @@ +import * as vscode from "vscode"; + +import { ObjectScriptDefinitionProvider } from "../../providers/ObjectScriptDefinitionProvider"; +import { lookupCcsDefinition } from "../features/definitionLookup/lookup"; + +export class PrioritizedDefinitionProvider implements vscode.DefinitionProvider { + private readonly delegate: ObjectScriptDefinitionProvider; + private readonly lookup: typeof lookupCcsDefinition; + + public constructor( + delegate: ObjectScriptDefinitionProvider, + lookupFn: typeof lookupCcsDefinition = lookupCcsDefinition + ) { + this.delegate = delegate; + this.lookup = lookupFn; + } + + public async provideDefinition( + document: vscode.TextDocument, + position: vscode.Position, + token: vscode.CancellationToken + ): Promise { + const location = await this.lookup(document, position, token, { + onNoResult: () => { + // No result from CCS resolver, fallback will be triggered + }, + }); + if (location) { + return location; + } + + return this.delegate.provideDefinition(document, position, token); + } +} diff --git a/src/ccs/sourcecontrol/clients/resolveDefinitionClient.ts b/src/ccs/sourcecontrol/clients/resolveDefinitionClient.ts new file mode 100644 index 00000000..acdc6905 --- /dev/null +++ b/src/ccs/sourcecontrol/clients/resolveDefinitionClient.ts @@ -0,0 +1,80 @@ +import axios from "axios"; +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 { ResolveDefinitionResponse } from "../../core/types"; +import { SourceControlApi } from "../client"; +import { ROUTES } from "../routes"; +import { toVscodeLocation } from "../paths"; + +export class ResolveDefinitionClient { + private readonly apiFactory: (api: AtelierAPI) => SourceControlApi; + + public constructor(apiFactory: (api: AtelierAPI) => SourceControlApi = SourceControlApi.fromAtelierApi) { + this.apiFactory = apiFactory; + } + + public async resolve( + document: vscode.TextDocument, + query: string, + token: vscode.CancellationToken + ): Promise { + const api = new AtelierAPI(document.uri); + const { host, port, username, password } = api.config; + const namespace = api.ns; + + if (!api.active || !namespace || !host || !port || !username || !password) { + logDebug("CCS definition lookup skipped due to missing connection metadata", { + active: api.active, + namespace, + host, + port, + username: Boolean(username), + password: Boolean(password), + }); + return undefined; + } + + let sourceControlApi: SourceControlApi; + try { + sourceControlApi = this.apiFactory(api); + } catch (error) { + logDebug("Failed to create SourceControl API client", error); + return undefined; + } + + const { requestTimeout } = getCcsSettings(); + const { signal, dispose } = createAbortSignal(token); + + try { + const response = await sourceControlApi.post( + ROUTES.resolveDefinition(namespace), + { query }, + { + timeout: requestTimeout, + signal, + validateStatus: (status) => status >= 200 && status < 300, + } + ); + + const location = toVscodeLocation(response.data ?? {}); + if (!location) { + logDebug("CCS definition lookup returned empty payload", response.data); + } + return location ?? undefined; + } catch (error) { + if (axios.isCancel(error)) { + logDebug("CCS definition lookup cancelled"); + return undefined; + } + + logDebug("CCS definition lookup failed", error); + return undefined; + } finally { + dispose(); + } + } +} diff --git a/src/ccs/sourcecontrol/paths.ts b/src/ccs/sourcecontrol/paths.ts new file mode 100644 index 00000000..c51ddcc8 --- /dev/null +++ b/src/ccs/sourcecontrol/paths.ts @@ -0,0 +1,36 @@ +import * as vscode from "vscode"; + +import { LocationJSON } from "../core/types"; + +export function normalizeFilePath(filePath: string): string { + if (!filePath) { + return filePath; + } + + const trimmed = filePath.trim(); + if (/^file:\/\//i.test(trimmed)) { + return trimmed.replace(/\\/g, "/"); + } + + const normalized = trimmed.replace(/\\/g, "/"); + return normalized; +} + +export function toFileUri(filePath: string): vscode.Uri { + const normalized = normalizeFilePath(filePath); + if (/^file:\/\//i.test(normalized)) { + return vscode.Uri.parse(normalized); + } + + return vscode.Uri.file(normalized); +} + +export function toVscodeLocation(location: LocationJSON): vscode.Location | undefined { + if (!location.uri || typeof location.line !== "number") { + return undefined; + } + + const uri = toFileUri(location.uri); + const zeroBasedLine = Math.max(0, Math.floor(location.line) - 1); + return new vscode.Location(uri, new vscode.Position(zeroBasedLine, 0)); +} diff --git a/src/ccs/sourcecontrol/routes.ts b/src/ccs/sourcecontrol/routes.ts index c379d7d5..1561570f 100644 --- a/src/ccs/sourcecontrol/routes.ts +++ b/src/ccs/sourcecontrol/routes.ts @@ -3,6 +3,7 @@ export const BASE_PATH = "/api/sourcecontrol/vscode" as const; export const ROUTES = { resolveContextExpression: () => `/resolveContextExpression`, getGlobalDocumentation: () => `/getGlobalDocumentation`, + resolveDefinition: (namespace: string) => `/namespaces/${encodeURIComponent(namespace)}/resolveDefinition`, } as const; export type RouteKey = keyof typeof ROUTES; diff --git a/src/extension.ts b/src/extension.ts index 0c4dd3ae..0cb43e3e 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -162,7 +162,13 @@ import { import { WorkspaceNode, NodeBase } from "./explorer/nodes"; import { showPlanWebview } from "./commands/showPlanPanel"; import { isfsConfig } from "./utils/FileProviderUtil"; -import { resolveContextExpression, showGlobalDocumentation } from "./ccs"; +import { + PrioritizedDefinitionProvider, + goToDefinitionLocalFirst, + resolveContextExpression, + showGlobalDocumentation, +} from "./ccs"; + const packageJson = vscode.extensions.getExtension(extensionId).packageJSON; const extensionVersion = packageJson.version; const aiKey = packageJson.aiKey; @@ -1017,7 +1023,7 @@ export async function activate(context: vscode.ExtensionContext): Promise { ), vscode.languages.registerDefinitionProvider( documentSelector(clsLangId, macLangId, intLangId, incLangId), - new ObjectScriptDefinitionProvider() + new PrioritizedDefinitionProvider(new ObjectScriptDefinitionProvider()) ), vscode.languages.registerCompletionItemProvider( documentSelector(clsLangId, macLangId, intLangId, incLangId), @@ -1263,6 +1269,10 @@ export async function activate(context: vscode.ExtensionContext): Promise { sendCommandTelemetryEvent("resolveContextExpression"); void resolveContextExpression(); }), + vscode.commands.registerCommand("vscode-objectscript.ccs.goToDefinition", async () => { + sendCommandTelemetryEvent("ccs.goToDefinition"); + await goToDefinitionLocalFirst(); + }), vscode.commands.registerCommand("vscode-objectscript.debug", (program: string, askArgs: boolean) => { sendCommandTelemetryEvent("debug"); const startDebugging = (args) => { @@ -2176,3 +2186,4 @@ export async function deactivate(): Promise { } await Promise.allSettled(promises); } +export { outputChannel }; From 67977ec05c87054aed6ba0082875b437c800c584 Mon Sep 17 00:00:00 2001 From: LeoAnders Date: Sat, 4 Oct 2025 19:51:22 -0300 Subject: [PATCH 3/5] feat: support cross-namespace definition lookup and request logging --- src/ccs/core/types.ts | 2 +- .../clients/resolveDefinitionClient.ts | 52 ++++++++++++++++++- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/src/ccs/core/types.ts b/src/ccs/core/types.ts index 311b15aa..707eb8ba 100644 --- a/src/ccs/core/types.ts +++ b/src/ccs/core/types.ts @@ -3,7 +3,7 @@ export interface LocationJSON { line?: number; } -export interface ResolveDefinitionResponse extends LocationJSON {} +export type ResolveDefinitionResponse = LocationJSON; export interface ResolveContextExpressionResponse { status?: string; diff --git a/src/ccs/sourcecontrol/clients/resolveDefinitionClient.ts b/src/ccs/sourcecontrol/clients/resolveDefinitionClient.ts index acdc6905..b2875f63 100644 --- a/src/ccs/sourcecontrol/clients/resolveDefinitionClient.ts +++ b/src/ccs/sourcecontrol/clients/resolveDefinitionClient.ts @@ -17,6 +17,46 @@ export class ResolveDefinitionClient { this.apiFactory = apiFactory; } + private getAdditionalNamespaces(currentApi: AtelierAPI): string[] { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders?.length) { + return []; + } + + const { host, port } = currentApi.config; + const currentPathPrefix = currentApi.config.pathPrefix ?? ""; + const currentNamespace = currentApi.ns; + + if (!host || !port) { + return []; + } + + const namespaces = new Set(); + + for (const folder of workspaceFolders) { + const folderApi = new AtelierAPI(folder.uri); + if (!folderApi.active) { + continue; + } + + const { host: folderHost, port: folderPort } = folderApi.config; + const folderPathPrefix = folderApi.config.pathPrefix ?? ""; + + if (folderHost !== host || folderPort !== port || folderPathPrefix !== currentPathPrefix) { + continue; + } + + const folderNamespace = folderApi.ns; + if (!folderNamespace || folderNamespace === currentNamespace) { + continue; + } + + namespaces.add(folderNamespace.toUpperCase()); + } + + return Array.from(namespaces); + } + public async resolve( document: vscode.TextDocument, query: string, @@ -49,10 +89,20 @@ export class ResolveDefinitionClient { const { requestTimeout } = getCcsSettings(); const { signal, dispose } = createAbortSignal(token); + const otherNamespaces = this.getAdditionalNamespaces(api); + const otherNamespacesStr = otherNamespaces.join(";"); + const body = otherNamespaces.length ? { query, otherNamespaces: otherNamespacesStr } : { query }; + + logDebug("CCS definition lookup request", { + namespace, + endpoint: ROUTES.resolveDefinition(namespace), + body, + }); + try { const response = await sourceControlApi.post( ROUTES.resolveDefinition(namespace), - { query }, + body, { timeout: requestTimeout, signal, From 2fb86274fad0a2c2291ee1a224aadd94340b5771 Mon Sep 17 00:00:00 2001 From: LeoAnders Date: Mon, 6 Oct 2025 01:13:48 -0300 Subject: [PATCH 4/5] feat: implement full Ctrl+Click support with `CCS API` resolution --- package.json | 5 + src/api/ccs/definitionResolver.ts | 68 ---- .../features/definitionLookup/extractQuery.ts | 339 +++++++++++++++--- src/ccs/index.ts | 11 +- .../DefinitionDocumentLinkProvider.ts | 24 ++ src/extension.ts | 31 ++ 6 files changed, 351 insertions(+), 127 deletions(-) delete mode 100644 src/api/ccs/definitionResolver.ts create mode 100644 src/ccs/providers/DefinitionDocumentLinkProvider.ts diff --git a/package.json b/package.json index eb6233b9..9fcbd6d6 100644 --- a/package.json +++ b/package.json @@ -874,6 +874,11 @@ "command": "vscode-objectscript.ccs.goToDefinition", "title": "Go to Definition" }, + { + "category": "ObjectScript", + "command": "vscode-objectscript.ccs.followDefinitionLink", + "title": "Follow Definition Link" + }, { "category": "ObjectScript", "command": "vscode-objectscript.compile", diff --git a/src/api/ccs/definitionResolver.ts b/src/api/ccs/definitionResolver.ts deleted file mode 100644 index 4577bf94..00000000 --- a/src/api/ccs/definitionResolver.ts +++ /dev/null @@ -1,68 +0,0 @@ -import axios, { AxiosInstance } from "axios"; -import * as vscode from "vscode"; -import { AtelierAPI } from "../"; - -interface ResolveDefinitionResponse { - uri?: string; - line?: number; -} - -export class DefinitionResolverApiClient { - private readonly axiosInstance: AxiosInstance; - - public constructor(axiosInstance: AxiosInstance = axios) { - this.axiosInstance = axiosInstance; - } - - public async resolve( - document: vscode.TextDocument, - query: string, - token: vscode.CancellationToken, - timeout = 500 - ): Promise { - const api = new AtelierAPI(document.uri); - const { https, host, port, pathPrefix, username, password } = api.config; - const ns = api.ns; - - if (!api.active || !ns || !host || !username || !password || !port) { - return undefined; - } - - const normalizedPrefix = pathPrefix ? (pathPrefix.startsWith("/") ? pathPrefix : `/${pathPrefix}`) : ""; - const trimmedPrefix = normalizedPrefix.endsWith("/") ? normalizedPrefix.slice(0, -1) : normalizedPrefix; - const baseUrl = `${https ? "https" : "http"}://${host}:${port}${trimmedPrefix}`; - const requestUrl = `${baseUrl}/api/sourcecontrol/vscode/namespaces/${encodeURIComponent(ns)}/resolveDefinition`; - - const controller = new AbortController(); - const disposeCancellation = token.onCancellationRequested(() => controller.abort()); - - try { - const response = await this.axiosInstance.post( - requestUrl, - { query }, - { - auth: { username, password }, - headers: { "Content-Type": "application/json" }, - timeout, - signal: controller.signal, - validateStatus: (status) => status >= 200 && status < 300, - } - ); - - const data = response.data; - if (data && typeof data.uri === "string" && data.uri.length && typeof data.line === "number") { - const zeroBasedLine = Math.max(0, Math.floor(data.line) - 1); - const targetUri = vscode.Uri.file(data.uri.replace(/\\/g, "/")); - return new vscode.Location(targetUri, new vscode.Position(zeroBasedLine, 0)); - } - } catch (error) { - if (!axios.isCancel(error)) { - // Swallow any errors to allow the native provider to handle the request. - } - } finally { - disposeCancellation.dispose(); - } - - return undefined; - } -} diff --git a/src/ccs/features/definitionLookup/extractQuery.ts b/src/ccs/features/definitionLookup/extractQuery.ts index 70f9ca93..4d214c09 100644 --- a/src/ccs/features/definitionLookup/extractQuery.ts +++ b/src/ccs/features/definitionLookup/extractQuery.ts @@ -7,82 +7,305 @@ export interface QueryMatch { normalizedQuery: string; kind: QueryKind; symbolName?: string; + range: vscode.Range; } +type DefinitionToken = QueryMatch & { activationRange: vscode.Range }; + +const LABEL_ROUTINE_REGEX = /\$\$([%A-Za-z][\w]*)\^([%A-Za-z][\w]*(?:\.[%A-Za-z][\w]*)*)/g; +const ROUTINE_INVOCATION_KEYWORDS = ["do", "job"]; +const ROUTINE_INVOCATION_PATTERN = ROUTINE_INVOCATION_KEYWORDS.join("|"); +const COMMAND_LABEL_ROUTINE_REGEX = new RegExp( + `\\b(?:${ROUTINE_INVOCATION_PATTERN})\\b\\s+([%A-Za-z][\\w]*)\\^([%A-Za-z][\\w]*(?:\\.[%A-Za-z][\\w]*)*)`, + "gi" +); +const COMMAND_ROUTINE_REGEX = new RegExp( + `\\b(?:${ROUTINE_INVOCATION_PATTERN})\\b\\s+\\^([%A-Za-z][\\w]*(?:\\.[%A-Za-z][\\w]*)*)`, + "gi" +); +const MACRO_REGEX = /\${3}([%A-Za-z][%A-Za-z0-9_]*)/g; +const CLASS_REFERENCE_REGEX = new RegExp( + "##class\\s*\\(\\s*([%A-Za-z][\\w]*(?:\\.[%A-Za-z][\\w]*)*)\\s*\\)(?:\\s*\\.\\s*([%A-Za-z][\\w]*))?", + "gi" +); + export function extractDefinitionQuery( document: vscode.TextDocument, position: vscode.Position ): QueryMatch | undefined { - const lineText = document.lineAt(position.line).text; - const charIndex = position.character; - - const labelRoutine = findMatch(lineText, /\$\$([%A-Za-z][\w]*)\^([%A-Za-z][\w]*(?:\.[%A-Za-z][\w]*)*)/g, charIndex); - if (labelRoutine) { - const [, , routineName] = labelRoutine.match; - const normalizedQuery = labelRoutine.text.replace(/^\$\$+/, ""); - return { - query: labelRoutine.text, - normalizedQuery, - kind: "labelRoutine", - symbolName: routineName, - }; - } - - const caretRoutine = findMatch(lineText, /\^(%?[A-Za-z][\w]*(?:\.[%A-Za-z][\w]*)*)/g, charIndex); - if (caretRoutine) { - const [, routineName] = caretRoutine.match; - return { - query: caretRoutine.text, - normalizedQuery: caretRoutine.text, - kind: "routine", - symbolName: routineName, - }; - } + const line = position.line; + const lineText = document.lineAt(line).text; + const tokens = collectDefinitionTokens(lineText, line); - const macro = findMatch(lineText, /\${3}([%A-Za-z][%A-Za-z0-9_]*)/g, charIndex); - if (macro) { - const [, macroName] = macro.match; - return { - query: macro.text, - normalizedQuery: macro.text, - kind: "macro", - symbolName: macroName, - }; + const directMatch = tokens.find((token) => containsPosition(token.range, position)); + if (directMatch) { + return withoutActivationRange(directMatch); } - const classRef = findMatch( - lineText, - /##class\s*\(\s*([%A-Za-z][\w]*(?:\.[%A-Za-z][\w]*)*)\s*\)(?:\s*\.\s*([%A-Za-z][\w]*))?/gi, - charIndex - ); - if (classRef) { - const [, className, methodName] = classRef.match; - const normalizedQuery = methodName ? `##class(${className}).${methodName}` : `##class(${className})`; - return { - query: classRef.text, - normalizedQuery, - kind: "class", - symbolName: className, - }; + const activationMatch = tokens.find((token) => containsPosition(token.activationRange, position)); + if (activationMatch) { + return withoutActivationRange(activationMatch); } return undefined; } -interface MatchResult { +export function extractDefinitionQueries(document: vscode.TextDocument): QueryMatch[] { + const matches: QueryMatch[] = []; + for (let line = 0; line < document.lineCount; line++) { + const lineText = document.lineAt(line).text; + const tokens = collectDefinitionTokens(lineText, line); + for (const token of tokens) { + matches.push(withoutActivationRange(token)); + } + } + return matches; +} + +interface MatchContext { + line: number; + start: number; text: string; match: RegExpExecArray; } -function findMatch(line: string, regex: RegExp, character: number): MatchResult | undefined { - regex.lastIndex = 0; - let match: RegExpExecArray | null = null; - while ((match = regex.exec(line)) !== null) { - const start = match.index; - const end = start + match[0].length; - if (character >= start && character <= end) { - return { text: match[0], match }; +interface DefinitionMatcher { + regex: RegExp; + buildTokens(context: MatchContext): DefinitionToken[]; +} + +const MATCHERS: DefinitionMatcher[] = [ + { + regex: LABEL_ROUTINE_REGEX, + buildTokens: ({ line, start, text, match }) => { + const [, labelName, routineName] = match; + const normalized = `${labelName}^${routineName}`; + const labelStart = start + 2; + const labelEnd = labelStart + labelName.length; + const caretIndex = text.indexOf("^"); + if (caretIndex < 0) { + return []; + } + const caretColumn = start + caretIndex; + const routineStart = caretColumn + 1; + const routineEnd = routineStart + routineName.length; + + return [ + createToken({ + line, + start: labelStart, + end: labelEnd, + query: text, + normalizedQuery: normalized, + kind: "labelRoutine", + symbolName: routineName, + }), + createToken({ + line, + start: routineStart, + end: routineEnd, + activationStart: caretColumn, + query: `^${routineName}`, + normalizedQuery: `^${routineName}`, + kind: "routine", + symbolName: routineName, + }), + ]; + }, + }, + { + regex: COMMAND_LABEL_ROUTINE_REGEX, + buildTokens: ({ line, start, text, match }) => { + const [, labelName, routineName] = match; + const normalized = `${labelName}^${routineName}`; + const labelOffset = text.indexOf(labelName); + if (labelOffset < 0) { + return []; + } + const labelStart = start + labelOffset; + const labelEnd = labelStart + labelName.length; + const caretIndex = text.indexOf("^"); + if (caretIndex < 0) { + return []; + } + const caretColumn = start + caretIndex; + const routineOffset = text.lastIndexOf(routineName); + if (routineOffset < 0) { + return []; + } + const routineStart = start + routineOffset; + const routineEnd = routineStart + routineName.length; + + return [ + createToken({ + line, + start: labelStart, + end: labelEnd, + query: normalized, + normalizedQuery: normalized, + kind: "labelRoutine", + symbolName: routineName, + }), + createToken({ + line, + start: routineStart, + end: routineEnd, + activationStart: caretColumn, + query: `^${routineName}`, + normalizedQuery: `^${routineName}`, + kind: "routine", + symbolName: routineName, + }), + ]; + }, + }, + { + regex: COMMAND_ROUTINE_REGEX, + buildTokens: ({ line, start, text, match }) => { + const [, routineName] = match; + const caretIndex = text.indexOf("^"); + if (caretIndex < 0) { + return []; + } + const caretColumn = start + caretIndex; + const routineOffset = text.lastIndexOf(routineName); + if (routineOffset < 0) { + return []; + } + const routineStart = start + routineOffset; + const routineEnd = routineStart + routineName.length; + + return [ + createToken({ + line, + start: routineStart, + end: routineEnd, + activationStart: caretColumn, + query: `^${routineName}`, + normalizedQuery: `^${routineName}`, + kind: "routine", + symbolName: routineName, + }), + ]; + }, + }, + { + regex: MACRO_REGEX, + buildTokens: ({ line, start, text, match }) => { + const [, macroName] = match; + const macroStart = start + (text.length - macroName.length); + if (macroStart < start) { + return []; + } + const macroEnd = macroStart + macroName.length; + + return [ + createToken({ + line, + start: macroStart, + end: macroEnd, + activationStart: start, + query: text, + normalizedQuery: text, + kind: "macro", + symbolName: macroName, + }), + ]; + }, + }, + { + regex: CLASS_REFERENCE_REGEX, + buildTokens: ({ line, start, text, match }) => { + const [, className, methodName] = match; + const classOffset = text.indexOf(className); + if (classOffset < 0) { + return []; + } + const classStart = start + classOffset; + const classEnd = classStart + className.length; + const tokens: DefinitionToken[] = [ + createToken({ + line, + start: classStart, + end: classEnd, + query: `##class(${className})`, + normalizedQuery: `##class(${className})`, + kind: "class", + symbolName: className, + }), + ]; + + if (methodName) { + const methodOffset = text.lastIndexOf(methodName); + if (methodOffset < 0) { + return tokens; + } + const methodStart = start + methodOffset; + const methodEnd = methodStart + methodName.length; + tokens.push( + createToken({ + line, + start: methodStart, + end: methodEnd, + query: `##class(${className}).${methodName}`, + normalizedQuery: `##class(${className}).${methodName}`, + kind: "class", + symbolName: className, + }) + ); + } + + return tokens; + }, + }, +]; + +function collectDefinitionTokens(lineText: string, line: number): DefinitionToken[] { + const tokens: DefinitionToken[] = []; + for (const matcher of MATCHERS) { + const regex = cloneRegex(matcher.regex); + let match: RegExpExecArray | null; + while ((match = regex.exec(lineText)) !== null) { + tokens.push(...matcher.buildTokens({ line, start: match.index, text: match[0], match })); + if (!regex.global) { + break; + } } } - return undefined; + return tokens; +} + +function createToken(options: { + line: number; + start: number; + end: number; + activationStart?: number; + query: string; + normalizedQuery: string; + kind: QueryKind; + symbolName?: string; +}): DefinitionToken { + const { line, start, end, activationStart = start } = options; + const activationEnd = Math.max(end, activationStart + 1); + return { + query: options.query, + normalizedQuery: options.normalizedQuery, + kind: options.kind, + symbolName: options.symbolName, + range: new vscode.Range(line, start, line, end), + activationRange: new vscode.Range(line, activationStart, line, activationEnd), + }; +} + +function withoutActivationRange(token: DefinitionToken): QueryMatch { + const { activationRange: _activationRange, ...rest } = token; + return rest; +} + +function containsPosition(range: vscode.Range, position: vscode.Position): boolean { + return position.isAfterOrEqual(range.start) && position.isBefore(range.end); +} + +function cloneRegex(regex: RegExp): RegExp { + return new RegExp(regex.source, regex.flags); } diff --git a/src/ccs/index.ts b/src/ccs/index.ts index 29874889..88212849 100644 --- a/src/ccs/index.ts +++ b/src/ccs/index.ts @@ -7,6 +7,15 @@ export { ContextExpressionClient } from "./sourcecontrol/clients/contextExpressi export { GlobalDocumentationClient } from "./sourcecontrol/clients/globalDocumentationClient"; export { ResolveDefinitionClient } from "./sourcecontrol/clients/resolveDefinitionClient"; export { lookupCcsDefinition, type LookupOptions } from "./features/definitionLookup/lookup"; -export { extractDefinitionQuery, type QueryMatch, type QueryKind } from "./features/definitionLookup/extractQuery"; +export { + extractDefinitionQuery, + extractDefinitionQueries, + type QueryMatch, + type QueryKind, +} from "./features/definitionLookup/extractQuery"; export { goToDefinitionLocalFirst } from "./commands/goToDefinitionLocalFirst"; export { PrioritizedDefinitionProvider } from "./providers/PrioritizedDefinitionProvider"; +export { + DefinitionDocumentLinkProvider, + followDefinitionLinkCommand, +} from "./providers/DefinitionDocumentLinkProvider"; diff --git a/src/ccs/providers/DefinitionDocumentLinkProvider.ts b/src/ccs/providers/DefinitionDocumentLinkProvider.ts new file mode 100644 index 00000000..5815ef5e --- /dev/null +++ b/src/ccs/providers/DefinitionDocumentLinkProvider.ts @@ -0,0 +1,24 @@ +import * as vscode from "vscode"; + +import { extractDefinitionQueries } from "../features/definitionLookup/extractQuery"; + +export const followDefinitionLinkCommand = "vscode-objectscript.ccs.followDefinitionLink"; + +export class DefinitionDocumentLinkProvider implements vscode.DocumentLinkProvider { + public provideDocumentLinks(document: vscode.TextDocument): vscode.DocumentLink[] { + const links: vscode.DocumentLink[] = []; + const queries = extractDefinitionQueries(document); + + for (const match of queries) { + const args = [document.uri.toString(), match.range.start.line, match.range.start.character]; + const commandUri = vscode.Uri.parse( + `command:${followDefinitionLinkCommand}?${encodeURIComponent(JSON.stringify(args))}` + ); + const link = new vscode.DocumentLink(match.range, commandUri); + link.tooltip = vscode.l10n.t("Go to Definition"); + links.push(link); + } + + return links; + } +} diff --git a/src/extension.ts b/src/extension.ts index 0cb43e3e..ff087068 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -164,6 +164,8 @@ import { showPlanWebview } from "./commands/showPlanPanel"; import { isfsConfig } from "./utils/FileProviderUtil"; import { PrioritizedDefinitionProvider, + DefinitionDocumentLinkProvider, + followDefinitionLinkCommand, goToDefinitionLocalFirst, resolveContextExpression, showGlobalDocumentation, @@ -962,6 +964,13 @@ export async function activate(context: vscode.ExtensionContext): Promise { const documentSelector = (...list) => ["file", ...schemas].reduce((acc, scheme) => acc.concat(list.map((language) => ({ scheme, language }))), []); + context.subscriptions.push( + vscode.languages.registerDocumentLinkProvider( + documentSelector(clsLangId, macLangId, intLangId, incLangId), + new DefinitionDocumentLinkProvider() + ) + ); + const diagnosticProvider = new ObjectScriptDiagnosticProvider(); // Gather the proposed APIs we will register to use when building with enabledApiProposals != [] @@ -1273,6 +1282,28 @@ export async function activate(context: vscode.ExtensionContext): Promise { sendCommandTelemetryEvent("ccs.goToDefinition"); await goToDefinitionLocalFirst(); }), + vscode.commands.registerCommand( + followDefinitionLinkCommand, + async (documentUri: string, line: number, character: number) => { + sendCommandTelemetryEvent("ccs.followDefinitionLink"); + if (!documentUri || typeof line !== "number" || typeof character !== "number") { + return; + } + + const uri = vscode.Uri.parse(documentUri); + const document = + vscode.workspace.textDocuments.find((doc) => doc.uri.toString() === documentUri) ?? + (await vscode.workspace.openTextDocument(uri)); + + const position = new vscode.Position(line, character); + const selectionRange = new vscode.Range(position, position); + const editor = await vscode.window.showTextDocument(document, { selection: selectionRange }); + editor.selection = new vscode.Selection(position, position); + editor.revealRange(selectionRange); + + await goToDefinitionLocalFirst(); + } + ), vscode.commands.registerCommand("vscode-objectscript.debug", (program: string, askArgs: boolean) => { sendCommandTelemetryEvent("debug"); const startDebugging = (args) => { From 80a0d1367115ef0c4301f7fcce8d5dc6ec678f0b Mon Sep 17 00:00:00 2001 From: LeoAnders Date: Mon, 6 Oct 2025 15:16:59 -0300 Subject: [PATCH 5/5] feat: remove visual underline from `DocumentLinks` in `DefinitionDocumentLinkProvider` --- .../DefinitionDocumentLinkProvider.ts | 175 +++++++++++++++++- src/extension.ts | 9 +- 2 files changed, 178 insertions(+), 6 deletions(-) diff --git a/src/ccs/providers/DefinitionDocumentLinkProvider.ts b/src/ccs/providers/DefinitionDocumentLinkProvider.ts index 5815ef5e..7fea9574 100644 --- a/src/ccs/providers/DefinitionDocumentLinkProvider.ts +++ b/src/ccs/providers/DefinitionDocumentLinkProvider.ts @@ -4,21 +4,186 @@ import { extractDefinitionQueries } from "../features/definitionLookup/extractQu export const followDefinitionLinkCommand = "vscode-objectscript.ccs.followDefinitionLink"; -export class DefinitionDocumentLinkProvider implements vscode.DocumentLinkProvider { +type TimeoutHandle = ReturnType; + +export class DefinitionDocumentLinkProvider implements vscode.DocumentLinkProvider, vscode.Disposable { + private readonly decorationType = vscode.window.createTextEditorDecorationType({ + textDecoration: "none", + }); + + private readonly _onDidChange = new vscode.EventEmitter(); + + public readonly onDidChange: vscode.Event = this._onDidChange.event; + + private readonly supportedLanguages?: Set; + + private readonly subscriptions: vscode.Disposable[] = []; + + private readonly linkRanges = new Map(); + + private readonly refreshTimeouts = new Map(); + + constructor(supportedLanguages?: readonly string[]) { + this.supportedLanguages = supportedLanguages?.length ? new Set(supportedLanguages) : undefined; + + this.subscriptions.push( + vscode.window.onDidChangeVisibleTextEditors(() => this.handleVisibleEditorsChange()), + vscode.window.onDidChangeActiveTextEditor(() => this.handleVisibleEditorsChange()), + vscode.workspace.onDidChangeTextDocument((event) => { + if (this.shouldHandleDocument(event.document)) { + this.scheduleRefresh(event.document); + } + }), + vscode.workspace.onDidCloseTextDocument((document) => this.clearDocument(document)) + ); + + this.handleVisibleEditorsChange(); + } + public provideDocumentLinks(document: vscode.TextDocument): vscode.DocumentLink[] { - const links: vscode.DocumentLink[] = []; const queries = extractDefinitionQueries(document); + this.updateDocumentRanges( + document, + queries.map((match) => match.range) + ); - for (const match of queries) { + return queries.map((match) => { const args = [document.uri.toString(), match.range.start.line, match.range.start.character]; const commandUri = vscode.Uri.parse( `command:${followDefinitionLinkCommand}?${encodeURIComponent(JSON.stringify(args))}` ); const link = new vscode.DocumentLink(match.range, commandUri); link.tooltip = vscode.l10n.t("Go to Definition"); - links.push(link); + return link; + }); + } + + public dispose(): void { + for (const timeout of this.refreshTimeouts.values()) { + clearTimeout(timeout); + } + this.refreshTimeouts.clear(); + + for (const disposable of this.subscriptions) { + disposable.dispose(); + } + + for (const editor of vscode.window.visibleTextEditors) { + editor.setDecorations(this.decorationType, []); + } + + this.linkRanges.clear(); + this.decorationType.dispose(); + this._onDidChange.dispose(); + } + + private handleVisibleEditorsChange(): void { + const visibleDocuments = new Set(); + + for (const editor of vscode.window.visibleTextEditors) { + if (!this.shouldHandleDocument(editor.document)) { + editor.setDecorations(this.decorationType, []); + continue; + } + + const key = editor.document.uri.toString(); + visibleDocuments.add(key); + + const ranges = this.linkRanges.get(key); + if (ranges) { + editor.setDecorations(this.decorationType, ranges); + } else { + editor.setDecorations(this.decorationType, []); + this.scheduleRefresh(editor.document); + } + } + + for (const key of [...this.linkRanges.keys()]) { + if (!visibleDocuments.has(key)) { + this.linkRanges.delete(key); + } + } + } + + private scheduleRefresh(document: vscode.TextDocument): void { + if (document.isClosed || !this.shouldHandleDocument(document)) { + return; + } + + const key = document.uri.toString(); + const existing = this.refreshTimeouts.get(key); + if (existing) { + clearTimeout(existing); + } + + const timeout = setTimeout(() => { + this.refreshTimeouts.delete(key); + if (document.isClosed) { + this.clearDocumentByKey(key); + return; + } + const queries = extractDefinitionQueries(document); + this.updateDocumentRanges( + document, + queries.map((match) => match.range) + ); + + this._onDidChange.fire(); + }, 50); + + this.refreshTimeouts.set(key, timeout); + } + + private updateDocumentRanges(document: vscode.TextDocument, ranges: vscode.Range[]): void { + const key = document.uri.toString(); + + const existing = this.refreshTimeouts.get(key); + if (existing) { + clearTimeout(existing); + this.refreshTimeouts.delete(key); + } + + if (ranges.length > 0) { + this.linkRanges.set(key, ranges); + } else { + this.linkRanges.delete(key); + } + + this.applyDecorationsForKey(key, ranges); + } + + private clearDocument(document: vscode.TextDocument): void { + this.clearDocumentByKey(document.uri.toString()); + } + + private clearDocumentByKey(key: string): void { + const timeout = this.refreshTimeouts.get(key); + if (timeout) { + clearTimeout(timeout); + this.refreshTimeouts.delete(key); + } + + this.linkRanges.delete(key); + this.applyDecorationsForKey(key, []); + } + + private applyDecorationsForKey(key: string, ranges: vscode.Range[]): void { + for (const editor of vscode.window.visibleTextEditors) { + if (editor.document.uri.toString() === key) { + editor.setDecorations(this.decorationType, ranges); + } + } + } + + private shouldHandleDocument(document: vscode.TextDocument): boolean { + if (document.isClosed) { + return false; + } + + if (this.supportedLanguages && !this.supportedLanguages.has(document.languageId)) { + return false; } - return links; + return true; } } diff --git a/src/extension.ts b/src/extension.ts index ff087068..095fa422 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -964,10 +964,17 @@ export async function activate(context: vscode.ExtensionContext): Promise { const documentSelector = (...list) => ["file", ...schemas].reduce((acc, scheme) => acc.concat(list.map((language) => ({ scheme, language }))), []); + const definitionDocumentLinkProvider = new DefinitionDocumentLinkProvider([ + clsLangId, + macLangId, + intLangId, + incLangId, + ]); context.subscriptions.push( + definitionDocumentLinkProvider, vscode.languages.registerDocumentLinkProvider( documentSelector(clsLangId, macLangId, intLangId, incLangId), - new DefinitionDocumentLinkProvider() + definitionDocumentLinkProvider ) );