From 45b7b825e98586861b99b6d2bb7cf765f394c353 Mon Sep 17 00:00:00 2001 From: Leonardo Anders <115679546+LeoAnders@users.noreply.github.com> Date: Sun, 21 Sep 2025 00:08:46 -0300 Subject: [PATCH] feat: enable cross-workspace definition lookup --- CHANGELOG.md | 4 + README.md | 28 +++++- package.json | 8 ++ src/providers/DocumentContentProvider.ts | 85 ++++++++++++++----- src/test/suite/extension.test.ts | 84 ++++++++++++++++-- .../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 ++- 10 files changed, 227 insertions(+), 32 deletions(-) create mode 100644 test-fixtures/multi-root/client/.vscode/settings.json create mode 100644 test-fixtures/multi-root/client/src/MultiRoot/Caller.cls create mode 100644 test-fixtures/multi-root/shared/.vscode/settings.json create mode 100644 test-fixtures/multi-root/shared/src/MultiRoot/Shared.cls diff --git a/CHANGELOG.md b/CHANGELOG.md index 5173f61a..27d62195 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Change Log +## [Unreleased] +- Enhancements + - Allow configuring cross-workspace Go to Definition lookups via `objectscript.export.searchOtherWorkspaceFolders` so local sources in sibling workspace folders are resolved before falling back to the server. + ## [3.0.6] 09-Sep-2025 - Enhancements - Add `objectscript.unitTest.enabled` setting (#1627) diff --git a/README.md b/README.md index b30f870d..3247a3c3 100644 --- a/README.md +++ b/README.md @@ -48,29 +48,51 @@ 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 92f69b74..e6d1de87 100644 --- a/package.json +++ b/package.json @@ -1396,6 +1396,14 @@ }, "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 c9eae4a4..24857303 100644 --- a/src/providers/DocumentContentProvider.ts +++ b/src/providers/DocumentContentProvider.ts @@ -165,30 +165,73 @@ export class DocumentContentProvider implements vscode.TextDocumentContentProvid }); } } else { - const conn = config("conn", workspaceFolder); + 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()) + ); if (!forceServerCopy) { - // Look for the document in the local file system - 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. + 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; - } 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 + }; + + // 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) { for (const wFolder of wFolders) { - 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; - } - } + 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; } } } diff --git a/src/test/suite/extension.test.ts b/src/test/suite/extension.test.ts index e162ec0b..d2cdb1a9 100644 --- a/src/test/suite/extension.test.ts +++ b/src/test/suite/extension.test.ts @@ -1,25 +1,99 @@ 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 { window, extensions } from "vscode"; -import { extensionId, smExtensionId } from "../../extension"; +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}'.`); +} + +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 = extensions.getExtension(smExtensionId); + const serverManager = vscode.extensions.getExtension(smExtensionId); await serverManager?.activate(); - const ext = extensions.getExtension(extensionId); + const ext = vscode.extensions.getExtension(extensionId); await ext?.activate(); }); before(() => { - window.showInformationMessage("Start all tests."); + vscode.window.showInformationMessage("Start all tests."); }); test("Sample test", () => { assert.ok("All good"); }); + + 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 new file mode 100644 index 00000000..c3b581df --- /dev/null +++ b/test-fixtures/multi-root/client/.vscode/settings.json @@ -0,0 +1,12 @@ +{ + "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 new file mode 100644 index 00000000..79cad05b --- /dev/null +++ b/test-fixtures/multi-root/client/src/MultiRoot/Caller.cls @@ -0,0 +1,10 @@ +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 new file mode 100644 index 00000000..4753cef3 --- /dev/null +++ b/test-fixtures/multi-root/shared/.vscode/settings.json @@ -0,0 +1,9 @@ +{ + "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 new file mode 100644 index 00000000..d176babf --- /dev/null +++ b/test-fixtures/multi-root/shared/src/MultiRoot/Shared.cls @@ -0,0 +1,9 @@ +Class MultiRoot.Shared Extends %RegisteredObject +{ + +ClassMethod Ping() +{ + Quit +} + +} diff --git a/test-fixtures/test.code-workspace b/test-fixtures/test.code-workspace index ba637669..e49cb155 100644 --- a/test-fixtures/test.code-workspace +++ b/test-fixtures/test.code-workspace @@ -1,15 +1,19 @@ { "folders": [ { - "path": "." + "name": "client", + "path": "multi-root/client" }, + { + "name": "shared", + "path": "multi-root/shared" + } ], "settings": { "objectscript.conn": { "active": false }, "objectscript.ignoreInstallServerManager": true, - "intersystems.servers": { - } + "intersystems.servers": {} } }