diff --git a/vscode/package-lock.json b/vscode/package-lock.json index b5d010ea..5f851abb 100644 --- a/vscode/package-lock.json +++ b/vscode/package-lock.json @@ -32,7 +32,8 @@ "sinon": "^20.0.0", "ts-mockito": "^2.6.1", "ts-node": "^10.9.2", - "typescript": "^5.8.3" + "typescript": "^5.8.3", + "vscode-uri": "^3.1.0" }, "engines": { "vscode": "^1.84.0" @@ -80,6 +81,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.27.7.tgz", "integrity": "sha512-BU2f9tlKQ5CAthiMIgpzAh4eDTLWo1mqi9jqE2OxMG0E/OM199VJt2q8BztTxpnSW0i1ymdwLXRJnYzvDM5r2w==", "dev": true, + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -1024,6 +1026,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.113.tgz", "integrity": "sha512-TmSTE9vyebJ9vSEiU+P+0Sp4F5tMgjiEOZaQUW6wA3ODvi6uBgkHQ+EsIu0pbiKvf9QHEvyRCiaz03rV0b+IaA==", "dev": true, + "peer": true, "dependencies": { "undici-types": "~5.26.4" } @@ -1269,6 +1272,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001726", "electron-to-chromium": "^1.5.173", @@ -2621,6 +2625,7 @@ "resolved": "https://registry.npmjs.org/nyc/-/nyc-17.1.0.tgz", "integrity": "sha512-U42vQ4czpKa0QdI1hu950XuNhYqgoM+ZF1HT+VuUHL9hPfDPVvNQyltmMqdE9bUHMVa+8yNbc3QKTj8zQhlVxQ==", "dev": true, + "peer": true, "dependencies": { "@istanbuljs/load-nyc-config": "^1.0.0", "@istanbuljs/schema": "^0.1.2", @@ -3828,6 +3833,7 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3943,6 +3949,13 @@ "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", "license": "MIT" }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/vscode/package.json b/vscode/package.json index 6d80e89f..1d41f9a8 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -933,7 +933,8 @@ "sinon": "^20.0.0", "ts-mockito": "^2.6.1", "ts-node": "^10.9.2", - "typescript": "^5.8.3" + "typescript": "^5.8.3", + "vscode-uri": "^3.1.0" }, "dependencies": { "@vscode/debugadapter": "^1.68.0", diff --git a/vscode/src/commands/create.ts b/vscode/src/commands/create.ts index 3f41b08e..8e05c823 100644 --- a/vscode/src/commands/create.ts +++ b/vscode/src/commands/create.ts @@ -13,15 +13,15 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { workspace, commands, Uri, window } from "vscode"; +import { workspace, commands, window } from "vscode"; import { LanguageClient } from "vscode-languageclient/node"; import { nbCommands, builtInCommands, extCommands } from "./commands"; import { l10n } from "../localiser"; import * as os from 'os'; import * as fs from 'fs'; import { ICommand } from "./types"; -import { getContextUri, isNbCommandRegistered } from "./utils"; -import { isString } from "../utils"; +import { getContextUriFromFile, isNbCommandRegistered } from "./utils"; +import { FileUtils, isString } from "../utils"; import { globalState } from "../globalState"; const newFromTemplate = async (ctx: any, template: any) => { @@ -40,7 +40,7 @@ const newFromTemplate = async (ctx: any, template: any) => { if (!fs.existsSync(folderPath)) { await fs.promises.mkdir(folderPath); } - const folderPathUri = Uri.file(folderPath); + const folderPathUri = FileUtils.toUri(folderPath); await commands.executeCommand(nbCommands.newFromTemplate, folderPathUri.toString()); await commands.executeCommand(builtInCommands.openFolder, folderPathUri); @@ -52,16 +52,16 @@ const newFromTemplate = async (ctx: any, template: any) => { if (isString(template)) { params.push(template); } - params.push(getContextUri(ctx)?.toString(), window.activeTextEditor?.document?.uri?.toString()); + params.push(getContextUriFromFile(ctx)?.toString(), window.activeTextEditor?.document?.uri?.toString()); const res = await commands.executeCommand(nbCommands.newFromTemplate, ...params); if (isString(res)) { - let newFile = Uri.parse(res as string); + let newFile = FileUtils.toUri(res as string, true); await window.showTextDocument(newFile, { preview: false }); } else if (Array.isArray(res)) { for (let r of res) { if (isString(r)) { - let newFile = Uri.parse(r as string); + let newFile = FileUtils.toUri(r as string, true); await window.showTextDocument(newFile, { preview: false }); } } @@ -74,9 +74,9 @@ const newFromTemplate = async (ctx: any, template: any) => { const newProject = async (ctx: any) => { const client: LanguageClient = await globalState.getClientPromise().client; if (await isNbCommandRegistered(nbCommands.newProject)) { - const res = await commands.executeCommand(nbCommands.newProject, getContextUri(ctx)?.toString()); + const res = await commands.executeCommand(nbCommands.newProject, getContextUriFromFile(ctx)?.toString()); if (isString(res)) { - let newProject = Uri.parse(res as string); + let newProject = FileUtils.toUri(res as string, true); const OPEN_IN_NEW_WINDOW = l10n.value("jdk.extension.label.openInNewWindow"); const ADD_TO_CURRENT_WORKSPACE = l10n.value("jdk.extension.label.addToWorkSpace"); diff --git a/vscode/src/commands/debug.ts b/vscode/src/commands/debug.ts index 653d8f51..c35a397b 100644 --- a/vscode/src/commands/debug.ts +++ b/vscode/src/commands/debug.ts @@ -15,10 +15,10 @@ */ import * as vscode from 'vscode'; -import { builtInCommands, extCommands } from "./commands"; +import { extCommands } from "./commands"; import { ICommand } from "./types"; import { extConstants } from '../constants'; -import { getContextUri } from './utils'; +import { getContextUriFromFile } from './utils'; const runTest = async (uri: any, methodName? : string, launchConfiguration?: string) => { await runDebug(true, true, uri, methodName, launchConfiguration); @@ -33,23 +33,23 @@ const debugSingle = async (uri: any, methodName? : string, launchConfiguration?: await runDebug(false, false, uri, methodName, launchConfiguration); } const projectRun = async (node: any, launchConfiguration? : string) => { - return runDebug(true, false, getContextUri(node)?.toString() || '', undefined, launchConfiguration, true); + return runDebug(true, false, getContextUriFromFile(node)?.toString() || '', undefined, launchConfiguration, true); } const projectDebug = async (node: any, launchConfiguration? : string) => { - return runDebug(false, false, getContextUri(node)?.toString() || '', undefined, launchConfiguration, true); + return runDebug(false, false, getContextUriFromFile(node)?.toString() || '', undefined, launchConfiguration, true); } const projectTest = async (node: any, launchConfiguration? : string) => { - return runDebug(true, true, getContextUri(node)?.toString() || '', undefined, launchConfiguration, true); + return runDebug(true, true, getContextUriFromFile(node)?.toString() || '', undefined, launchConfiguration, true); } const projectTestDebug = async (node: any, launchConfiguration? : string) => { - return runDebug(false, true, getContextUri(node)?.toString() || '', undefined, launchConfiguration, true); + return runDebug(false, true, getContextUriFromFile(node)?.toString() || '', undefined, launchConfiguration, true); } const packageTest = async (uri: any, launchConfiguration? : string) => { await runDebug(true, true, uri, undefined, launchConfiguration); } const runDebug = async (noDebug: boolean, testRun: boolean, uri: any, methodName?: string, launchConfiguration?: string, project : boolean = false, ) => { - const docUri = getContextUri(uri); + const docUri = getContextUriFromFile(uri); if (docUri) { let debugConfig : vscode.DebugConfiguration = { type: extConstants.COMMAND_PREFIX, diff --git a/vscode/src/commands/navigation.ts b/vscode/src/commands/navigation.ts index c46da232..2aba8d30 100644 --- a/vscode/src/commands/navigation.ts +++ b/vscode/src/commands/navigation.ts @@ -13,21 +13,22 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { commands, Position, window, Selection, Range, Uri } from "vscode"; +import { commands, Position, window, Selection, Range } from "vscode"; import { builtInCommands, extCommands, nbCommands } from "./commands"; import { l10n } from "../localiser"; import * as path from 'path'; import { ICommand } from "./types"; import { LanguageClient } from "vscode-languageclient/node"; import { LOGGER } from '../logger'; -import { getContextUri, isNbCommandRegistered, wrapCommandWithProgress } from "./utils"; +import { getContextUriFromFile, isNbCommandRegistered, wrapCommandWithProgress } from "./utils"; import { globalState } from "../globalState"; +import { FileUtils } from "../utils"; const goToTest = async (ctx: any) => { let client: LanguageClient = await globalState.getClientPromise().client; if (await isNbCommandRegistered(nbCommands.goToTest)) { try { - const res: any = await commands.executeCommand(nbCommands.goToTest, getContextUri(ctx)?.toString()); + const res: any = await commands.executeCommand(nbCommands.goToTest, getContextUriFromFile(ctx)?.toString()); if ("errorMessage" in res) { throw new Error(res.errorMessage); } @@ -39,7 +40,8 @@ const goToTest = async (ctx: any) => { if (res?.locations?.length) { if (res.locations.length === 1) { const { file, offset } = res.locations[0]; - const filePath = Uri.parse(file); + // If in the future the GoToTest returns URI locations then pass true as an extra parameter to FileUtils.toUri + const filePath = FileUtils.toUri(file); const editor = await window.showTextDocument(filePath, { preview: false }); if (offset != -1) { const pos: Position = editor.document.positionAt(offset); @@ -61,7 +63,8 @@ const goToTest = async (ctx: any) => { }); if (selected) { for await (const filePath of selected) { - let file = Uri.parse(filePath); + // If in the future the GoToTest returns URI locations then pass true as an extra parameter to FileUtils.toUri + let file = FileUtils.toUri(filePath); await window.showTextDocument(file, { preview: false }); } } else { @@ -87,7 +90,7 @@ const openStackHandler = async (uri: any, methodName: any, fileName: any, line: const location: string | undefined = uri ? await commands.executeCommand(nbCommands.resolveStackLocation, uri, methodName, fileName) : undefined; if (location) { const lNum = line - 1; - window.showTextDocument(Uri.parse(location), { selection: new Range(new Position(lNum, 0), new Position(lNum, 0)) }); + window.showTextDocument(FileUtils.toUri(location, true), { selection: new Range(new Position(lNum, 0), new Position(lNum, 0)) }); } else { if (methodName) { const fqn: string = methodName.substring(0, methodName.lastIndexOf('.')); diff --git a/vscode/src/commands/notebook.ts b/vscode/src/commands/notebook.ts index d68664a3..794f10bd 100644 --- a/vscode/src/commands/notebook.ts +++ b/vscode/src/commands/notebook.ts @@ -3,12 +3,12 @@ import * as path from 'path'; import * as fs from 'fs'; import { LOGGER } from '../logger'; import { commands, ConfigurationTarget, Uri, window, workspace } from 'vscode'; -import { isError } from '../utils'; +import { FileUtils, isError } from '../utils'; import { extCommands, nbCommands } from './commands'; import { ICommand } from './types'; import { LanguageClient } from 'vscode-languageclient/node'; import { globalState } from '../globalState'; -import { getContextUri, isNbCommandRegistered } from './utils'; +import { getContextUriFromFile, isNbCommandRegistered } from './utils'; import { l10n } from '../localiser'; import { extConstants } from '../constants'; import { Notebook } from '../notebooks/notebook'; @@ -26,7 +26,7 @@ const createNewNotebook = async (ctx?: any) => { const activeFilePath = window.activeTextEditor?.document.uri; if (activeFilePath) { - const parentDir = Uri.parse(path.dirname(activeFilePath.fsPath)); + const parentDir = FileUtils.toUri(path.dirname(activeFilePath.fsPath)); if (workspace.getWorkspaceFolder(parentDir)) { defaultUri = parentDir; } @@ -46,7 +46,7 @@ const createNewNotebook = async (ctx?: any) => { } } if (defaultUri == null) { - defaultUri = Uri.parse(os.homedir()); + defaultUri = FileUtils.toUri(os.homedir()); } } @@ -63,7 +63,7 @@ const createNewNotebook = async (ctx?: any) => { notebookDir = nbFolderPath[0]; } } else { - notebookDir = getContextUri(ctx) || null; + notebookDir = getContextUriFromFile(ctx) || null; } if (notebookDir == null) { window.showErrorMessage(l10n.value("jdk.notebook.create.error_msg.path.not.selected")); @@ -109,7 +109,7 @@ const createNewNotebook = async (ctx?: any) => { LOGGER.log(`Created notebook at: ${finalNotebookPath}`); - const notebookUri = Uri.file(finalNotebookPath); + const notebookUri = FileUtils.toUri(finalNotebookPath); const notebookDocument = await workspace.openNotebookDocument(notebookUri); await window.showNotebookDocument(notebookDocument); } catch (error) { diff --git a/vscode/src/commands/refactor.ts b/vscode/src/commands/refactor.ts index b6b402b5..9be3dae7 100644 --- a/vscode/src/commands/refactor.ts +++ b/vscode/src/commands/refactor.ts @@ -13,7 +13,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { commands, window, Uri, Range, Location, workspace, Position } from "vscode"; +import { commands, window, Range, Location, workspace, Position } from "vscode"; import { ICommand } from "./types"; import { extConstants } from "../constants"; import { builtInCommands, extCommands, nbCommands } from "./commands"; @@ -21,6 +21,7 @@ import { l10n } from "../localiser"; import { WorkspaceEdit } from 'vscode-languageserver-protocol'; import { SymbolInformation } from 'vscode-languageclient'; import { globalState } from "../globalState"; +import { FileUtils } from "../utils"; const goToSuperImplementationHandler = async () => { if (window.activeTextEditor?.document.languageId !== extConstants.LANGUAGE_ID) { @@ -30,7 +31,7 @@ const goToSuperImplementationHandler = async () => { const position = window.activeTextEditor.selection.active; const locations: any[] = await commands.executeCommand(nbCommands.superImpl, uri.toString(), position) || []; return commands.executeCommand(builtInCommands.goToEditorLocations, window.activeTextEditor.document.uri, position, - locations.map(location => new Location(Uri.parse(location.uri), new Range(location.range.start.line, location.range.start.character, location.range.end.line, location.range.end.character))), + locations.map(location => new Location(FileUtils.toUri(location.uri, true), new Range(location.range.start.line, location.range.start.character, location.range.end.line, location.range.end.character))), 'peek', l10n.value('jdk.extension.error_msg.noSuperImpl')); } diff --git a/vscode/src/commands/utils.ts b/vscode/src/commands/utils.ts index 6e787523..be530a68 100644 --- a/vscode/src/commands/utils.ts +++ b/vscode/src/commands/utils.ts @@ -20,20 +20,17 @@ import { LanguageClient } from "vscode-languageclient/node"; import { l10n } from "../localiser"; import { LOGGER } from "../logger"; import { globalState } from "../globalState"; +import { FileUtils, isString } from "../utils"; -export const getContextUri = (ctx: any): Uri | undefined => { +export const getContextUriFromFile = (ctx: any): Uri | undefined => { if (ctx?.fsPath) { return ctx as Uri; } if (ctx?.resourceUri) { return ctx.resourceUri as Uri; } - if (typeof ctx == 'string') { - try { - return Uri.parse(ctx, true); - } catch (err) { - return Uri.file(ctx); - } + if (isString(ctx)) { + return FileUtils.toUri(ctx, true); } return window.activeTextEditor?.document?.uri; diff --git a/vscode/src/localiser.ts b/vscode/src/localiser.ts index 4906909b..cf88c31a 100644 --- a/vscode/src/localiser.ts +++ b/vscode/src/localiser.ts @@ -20,6 +20,7 @@ import * as l10nLib from '@vscode/l10n' import * as vscode from 'vscode'; import { extConstants } from './constants'; +import { FileUtils } from './utils'; const DEFAULT_LANGAUGE = "en"; const DEFAULT_BUNDLE_FILE = `l10n/bundle.l10n.${DEFAULT_LANGAUGE}.json`; @@ -36,7 +37,7 @@ class l10Wrapper implements l10n { private defaultTranslation: TranslatorFn; constructor(extensionId: string, defaultBundlePath: string) { - let defaultBundleAbsoluteFsPath = vscode.Uri.file(`${vscode.extensions.getExtension(extensionId)?.extensionPath}/${defaultBundlePath}`).fsPath + let defaultBundleAbsoluteFsPath = FileUtils.toUri(`${vscode.extensions.getExtension(extensionId)?.extensionPath}/${defaultBundlePath}`).fsPath; l10nLib.config({ fsPath: defaultBundleAbsoluteFsPath }); diff --git a/vscode/src/telemetry/utils.ts b/vscode/src/telemetry/utils.ts index 5c9ab426..2f9818ae 100644 --- a/vscode/src/telemetry/utils.ts +++ b/vscode/src/telemetry/utils.ts @@ -16,7 +16,7 @@ import * as crypto from 'crypto'; import { Uri, workspace } from 'vscode'; import * as os from 'os'; -import { isObject, isString } from '../utils'; +import { FileUtils, isObject, isString } from '../utils'; export const getCurrentUTCDateInSeconds = () => { const date = Date.now(); @@ -70,7 +70,7 @@ const getUri = (pathOrUri: Uri | string): Uri => { if (pathOrUri instanceof Uri) { return pathOrUri; } - return Uri.file(pathOrUri); + return FileUtils.toUri(pathOrUri); } export const getValuesToBeTransformed = (): string[] => { diff --git a/vscode/src/test/unit/general/utils.unit.test.ts b/vscode/src/test/unit/general/utils.unit.test.ts new file mode 100644 index 00000000..27d367b7 --- /dev/null +++ b/vscode/src/test/unit/general/utils.unit.test.ts @@ -0,0 +1,292 @@ +/* + Copyright (c) 2025, Oracle and/or its affiliates. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ +import { expect } from 'chai'; +import { describe, it, beforeEach, afterEach } from "mocha"; +import * as sinon from 'sinon'; +import * as vscode from 'vscode'; +import { FileUtils } from '../../../utils'; +import { LOGGER } from '../../../logger'; +import * as path from 'path'; + +describe('FileUtils.toUri', () => { + let loggerLogStub: sinon.SinonStub; + const URI_SCHEME_FILE = "file"; + + beforeEach(() => { + loggerLogStub = sinon.stub(LOGGER, 'log'); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe('treatAsUriIfPossible = true', () => { + describe('successful URI parsing', () => { + it('should parse a valid file URI using vscode.Uri.parse', () => { + const filePath = '/path/to/file.java'; + const uriString = `file://${filePath}`; + + const result = FileUtils.toUri(uriString, true); + + expect(result.scheme).to.equal(URI_SCHEME_FILE); + expect(result.path).to.equal(filePath); + expect(result.toString()).to.equal(uriString); + expect(loggerLogStub.called).to.be.false; + }); + + it('should parse a valid file URI with triple slash', () => { + const uriString = 'file:///path/to/file.java'; + + const result = FileUtils.toUri(uriString, true); + + expect(result.scheme).to.equal(URI_SCHEME_FILE); + expect(result.path).to.equal('/path/to/file.java'); + expect(loggerLogStub.called).to.be.false; + }); + + it('should parse a valid file URI with Windows-like path', () => { + const uriString = 'file:///c:/username/home/file.java'; + + const result = FileUtils.toUri(uriString, true); + + expect(result.scheme).to.equal(URI_SCHEME_FILE); + expect(result.path).to.include('/c:'); + expect(loggerLogStub.called).to.be.false; + }); + + it('should parse file URI with query and fragment', () => { + const uriString = 'file:///path/to/file.java?query=1#fragment'; + + const result = FileUtils.toUri(uriString, true); + + expect(result.scheme).to.equal('file'); + expect(result.path).to.equal('/path/to/file.java'); + expect(result.query).to.equal('query=1'); + expect(result.fragment).to.equal('fragment'); + expect(loggerLogStub.called).to.be.false; + }); + + it('should parse file URI with special characters', () => { + const uriString = 'file:///path/with%20spaces/and-special%21chars.java'; + + const result = FileUtils.toUri(uriString, true); + + expect(result.scheme).to.equal(URI_SCHEME_FILE); + expect(result.path).to.include('path'); + expect(result.path).to.include('chars.java'); + expect(loggerLogStub.called).to.be.false; + }); + }); + + describe('non-file URI strings with treatAsUriIfPossible = true', () => { + it('should treat non-file URI as file path', () => { + const uriString = 'https://example.com/file.java'; + + const result = FileUtils.toUri(uriString, true); + + expect(result.scheme).to.equal('file'); + expect(loggerLogStub.called).to.be.false; + }); + + it('should treat regular path as file path even with treatAsUriIfPossible', () => { + const filePath = '/absolute/path/to/file.java'; + + const result = FileUtils.toUri(filePath, true); + + expect(result.scheme).to.equal('file'); + expect(result.path).to.equal(filePath); + expect(loggerLogStub.called).to.be.false; + }); + }); + }); + + describe('treatAsUriIfPossible = false (default)', () => { + describe('file path parsing', () => { + it('should parse absolute Unix path as file URI', () => { + const filePath = '/absolute/path/to/file.java'; + + const result = FileUtils.toUri(filePath); + + expect(result.scheme).to.equal('file'); + expect(result.path).to.equal(filePath); + expect(loggerLogStub.called).to.be.false; + }); + + it('should parse file path with query and fragment ', () => { + const pathString = '/path/to/file.java?query=1#fragment'; + + const result = FileUtils.toUri(pathString); + + expect(result.path).to.equal(pathString); + expect(result.query).to.equal(''); + expect(result.fragment).to.equal(''); + expect(loggerLogStub.called).to.be.false; + }); + + it('should parse Windows file paths', () => { + const filePath = 'C:\\Users\\path\\to\\file.java'; + + const result = FileUtils.toUri(filePath); + + expect(result.scheme).to.equal('file'); + expect(path.normalize(result.fsPath).toLowerCase()).to.equal(path.normalize(filePath).toLowerCase()); + expect(loggerLogStub.called).to.be.false; + }); + + it('should parse Windows file paths with lowercase drive', () => { + const filePath = 'c:\\Users\\path\\to\\file.java'; + + const result = FileUtils.toUri(filePath); + + expect(result.scheme).to.equal('file'); + expect(path.normalize(result.fsPath).toLowerCase()).to.equal(path.normalize(filePath).toLowerCase()); + expect(loggerLogStub.called).to.be.false; + }); + + it('should handle relative paths', () => { + const relativePath = './src/main/java/Main.java'; + + const result = FileUtils.toUri(relativePath); + + expect(result.scheme).to.equal(URI_SCHEME_FILE); + const normalizedPath = result.path.replace(/\\/g, '/'); + expect(normalizedPath).to.include('src/main/java/Main.java'); + expect(loggerLogStub.called).to.be.false; + }); + + it('should handle UNC paths', () => { + const uncPath = '\\\\server\\share\\file.java'; + + const result = FileUtils.toUri(uncPath); + + expect(result.scheme).to.equal(URI_SCHEME_FILE); + expect(result.fsPath).to.include('server'); + expect(result.fsPath).to.include('share'); + expect(result.fsPath).to.include('file.java'); + expect(loggerLogStub.called).to.be.false; + }); + + it('should treat file:// strings as regular paths when treatAsUriIfPossible is false', () => { + const uriString = 'file:///path/to/file.java'; + + const result = FileUtils.toUri(uriString, false); + + expect(result.scheme).to.equal('file'); + expect(loggerLogStub.called).to.be.false; + }); + }); + }); + + describe('error handling', () => { + it('should throw and log error when URI parsing fails', () => { + const invalidUri = 'file://::invalid::'; + sinon.stub(vscode.Uri, 'parse').throws(new Error('Invalid URI format')); + + expect(() => FileUtils.toUri(invalidUri, true)).to.throw('Error while parsing URI'); + expect(loggerLogStub.calledOnce).to.be.true; + expect(loggerLogStub.firstCall.args[0]).to.include('Error while parsing uri'); + expect(loggerLogStub.firstCall.args[0]).to.include('Invalid URI format'); + }); + + it('should throw and log error when file path parsing fails', () => { + const invalidPath = 'some/path'; + const errorMessage = 'File parse failed'; + sinon.stub(vscode.Uri, 'file').throws(new Error(errorMessage)); + + expect(() => FileUtils.toUri(invalidPath)).to.throw('Error while parsing URI'); + expect(loggerLogStub.calledOnce).to.be.true; + expect(loggerLogStub.firstCall.args[0]).to.contain(errorMessage); + }); + + it('should handle non-Error objects in catch block', () => { + const path = '/some/path'; + sinon.stub(vscode.Uri, 'file').throws('string error'); + + expect(() => FileUtils.toUri(path)).to.throw('Error while parsing URI'); + expect(loggerLogStub.calledOnce).to.be.true; + expect(loggerLogStub.firstCall.args[0]).to.include('string error'); + }); + }); + + describe('edge cases', () => { + it('should handle empty string', () => { + const emptyPath = ''; + + const result = FileUtils.toUri(emptyPath); + + expect(result.scheme).to.equal(URI_SCHEME_FILE); + expect(result.path).to.satisfy((p: string) => p === '/' || p.match(/^\/[a-z]:/i)); + expect(loggerLogStub.called).to.be.false; + }); + + it('should handle paths with spaces and special characters', () => { + const filePath = '/path/with spaces/and-special!chars.java'; + + const result = FileUtils.toUri(filePath); + + expect(result.scheme).to.equal(URI_SCHEME_FILE); + expect(result.fsPath).to.include('special'); + expect(loggerLogStub.called).to.be.false; + }); + + it('should differentiate between file: prefix with and without treatAsUriIfPossible', () => { + const uriString = 'file:///path/to/file.java'; + + const resultAsUri = FileUtils.toUri(uriString, true); + const resultAsPath = FileUtils.toUri(uriString, false); + + expect(resultAsUri.scheme).to.equal('file'); + expect(resultAsPath.scheme).to.equal('file'); + expect(resultAsUri.path).to.equal('/path/to/file.java'); + expect(loggerLogStub.called).to.be.false; + }); + + it('should handle file: scheme with single slash', () => { + const uriString = 'file:/path/to/file.java'; + + const result = FileUtils.toUri(uriString, true); + + expect(result.scheme).to.equal('file'); + expect(loggerLogStub.called).to.be.false; + }); + }); + + describe('parameter validation', () => { + it('should default treatAsUriIfPossible to false', () => { + const filePath = '/some/path/file.txt'; + + const resultDefault = FileUtils.toUri(filePath); + const resultExplicit = FileUtils.toUri(filePath, false); + + expect(resultDefault.toString()).to.equal(resultExplicit.toString()); + expect(loggerLogStub.called).to.be.false; + }); + + it('should only parse as URI when both treatAsUriIfPossible is true AND path starts with file:', () => { + const regularPath = '/regular/path.txt'; + const fileUriPath = 'file:///uri/path.txt'; + + const result1 = FileUtils.toUri(regularPath, true); + const result2 = FileUtils.toUri(fileUriPath, true); + const result3 = FileUtils.toUri(fileUriPath, false); + + expect(result1.path).to.equal(regularPath); + expect(result2.path).to.equal('/uri/path.txt'); + expect(result3.scheme).to.equal('file'); + expect(loggerLogStub.called).to.be.false; + }); + }); +}); \ No newline at end of file diff --git a/vscode/src/test/unit/mocks/vscode/mockVscode.ts b/vscode/src/test/unit/mocks/vscode/mockVscode.ts index 2478173c..027f3e25 100644 --- a/vscode/src/test/unit/mocks/vscode/mockVscode.ts +++ b/vscode/src/test/unit/mocks/vscode/mockVscode.ts @@ -15,7 +15,7 @@ */ import * as vscode from 'vscode'; -import { URI } from './uri'; +import { URI } from 'vscode-uri'; import { mockWindowNamespace } from './namespaces/window'; import { mockEnvNamespace } from './namespaces/env'; import { mockedEnums } from './vscodeHostedTypes'; diff --git a/vscode/src/test/unit/mocks/vscode/uri.ts b/vscode/src/test/unit/mocks/vscode/uri.ts deleted file mode 100644 index 80a828b0..00000000 --- a/vscode/src/test/unit/mocks/vscode/uri.ts +++ /dev/null @@ -1,758 +0,0 @@ -/* - Copyright (c) 2024, Oracle and/or its affiliates. - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - https://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - - This file includes work covered by the following copyright and permission notices: - - == https://github.com/microsoft/vscode-python/blob/main/src/test/mocks/vsc/uri.ts == - Copyright (c) Microsoft Corporation. All rights reserved. - MIT License - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - -*/ - -'use strict'; - -import * as pathImport from 'path'; -import { CharCode } from './charCode'; - -const isWindows = /^win/.test(process.platform); - -const _schemePattern = /^\w[\w\d+.-]*$/; -const _singleSlashStart = /^\//; -const _doubleSlashStart = /^\/\//; - -const _empty = ''; -const _slash = '/'; -const _regexp = /^(([^:/?#]+?):)?(\/\/([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?/; - -const _pathSepMarker = isWindows ? 1 : undefined; - -let _throwOnMissingSchema = true; - -/** - * @internal - */ -export function setUriThrowOnMissingScheme(value: boolean): boolean { - const old = _throwOnMissingSchema; - _throwOnMissingSchema = value; - return old; -} - -function _validateUri(ret: URI, _strict?: boolean): void { - // scheme, must be set - // if (!ret.scheme) { - // // if (_strict || _throwOnMissingSchema) { - // // throw new Error(`[UriError]: Scheme is missing: {scheme: "", authority: "${ret.authority}", path: "${ret.path}", query: "${ret.query}", fragment: "${ret.fragment}"}`); - // // } else { - // console.warn(`[UriError]: Scheme is missing: {scheme: "", authority: "${ret.authority}", path: "${ret.path}", query: "${ret.query}", fragment: "${ret.fragment}"}`); - // // } - // } - - // scheme, https://tools.ietf.org/html/rfc3986#section-3.1 - // ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) - if (ret.scheme && !_schemePattern.test(ret.scheme)) { - throw new Error('[UriError]: Scheme contains illegal characters.'); - } - - // path, http://tools.ietf.org/html/rfc3986#section-3.3 - // If a URI contains an authority component, then the path component - // must either be empty or begin with a slash ("/") character. If a URI - // does not contain an authority component, then the path cannot begin - // with two slash characters ("//"). - if (ret.path) { - if (ret.authority) { - if (!_singleSlashStart.test(ret.path)) { - throw new Error( - '[UriError]: If a URI contains an authority component, then the path component must either be empty or begin with a slash ("/") character', - ); - } - } else if (_doubleSlashStart.test(ret.path)) { - throw new Error( - '[UriError]: If a URI does not contain an authority component, then the path cannot begin with two slash characters ("//")', - ); - } - } -} - -// for a while we allowed uris *without* schemes and this is the migration -// for them, e.g. an uri without scheme and without strict-mode warns and falls -// back to the file-scheme. that should cause the least carnage and still be a -// clear warning -function _schemeFix(scheme: string, _strict: boolean): string { - if (_strict || _throwOnMissingSchema) { - return scheme || _empty; - } - if (!scheme) { - console.trace('BAD uri lacks scheme, falling back to file-scheme.'); - scheme = 'file'; - } - return scheme; -} - -// implements a bit of https://tools.ietf.org/html/rfc3986#section-5 -function _referenceResolution(scheme: string, path: string): string { - // the slash-character is our 'default base' as we don't - // support constructing URIs relative to other URIs. This - // also means that we alter and potentially break paths. - // see https://tools.ietf.org/html/rfc3986#section-5.1.4 - switch (scheme) { - case 'https': - case 'http': - case 'file': - if (!path) { - path = _slash; - } else if (path[0] !== _slash) { - path = _slash + path; - } - break; - default: - break; - } - return path; -} - -/** - * Uniform Resource Identifier (URI) http://tools.ietf.org/html/rfc3986. - * This class is a simple parser which creates the basic component parts - * (http://tools.ietf.org/html/rfc3986#section-3) with minimal validation - * and encoding. - * - * foo://example.com:8042/over/there?name=ferret#nose - * \_/ \______________/\_________/ \_________/ \__/ - * | | | | | - * scheme authority path query fragment - * | _____________________|__ - * / \ / \ - * urn:example:animal:ferret:nose - */ - -export class URI implements UriComponents { - static isUri(thing: unknown): thing is URI { - if (thing instanceof URI) { - return true; - } - if (!thing) { - return false; - } - return ( - typeof (thing).authority === 'string' && - typeof (thing).fragment === 'string' && - typeof (thing).path === 'string' && - typeof (thing).query === 'string' && - typeof (thing).scheme === 'string' && - typeof (thing).fsPath === 'function' && - typeof (thing).with === 'function' && - typeof (thing).toString === 'function' - ); - } - - /** - * scheme is the 'http' part of 'http://www.msft.com/some/path?query#fragment'. - * The part before the first colon. - */ - readonly scheme: string; - - /** - * authority is the 'www.msft.com' part of 'http://www.msft.com/some/path?query#fragment'. - * The part between the first double slashes and the next slash. - */ - readonly authority: string; - - /** - * path is the '/some/path' part of 'http://www.msft.com/some/path?query#fragment'. - */ - readonly path: string; - - /** - * query is the 'query' part of 'http://www.msft.com/some/path?query#fragment'. - */ - readonly query: string; - - /** - * fragment is the 'fragment' part of 'http://www.msft.com/some/path?query#fragment'. - */ - readonly fragment: string; - - /** - * @internal - */ - protected constructor( - scheme: string, - authority?: string, - path?: string, - query?: string, - fragment?: string, - _strict?: boolean, - ); - - /** - * @internal - */ - protected constructor(components: UriComponents); - - /** - * @internal - */ - protected constructor( - schemeOrData: string | UriComponents, - authority?: string, - path?: string, - query?: string, - fragment?: string, - _strict = false, - ) { - if (typeof schemeOrData === 'object') { - this.scheme = schemeOrData.scheme || _empty; - this.authority = schemeOrData.authority || _empty; - this.path = schemeOrData.path || _empty; - this.query = schemeOrData.query || _empty; - this.fragment = schemeOrData.fragment || _empty; - // no validation because it's this URI - // that creates uri components. - // _validateUri(this); - } else { - this.scheme = _schemeFix(schemeOrData, _strict); - this.authority = authority || _empty; - this.path = _referenceResolution(this.scheme, path || _empty); - this.query = query || _empty; - this.fragment = fragment || _empty; - - _validateUri(this, _strict); - } - } - - // ---- filesystem path ----------------------- - - /** - * Returns a string representing the corresponding file system path of this URI. - * Will handle UNC paths, normalizes windows drive letters to lower-case, and uses the - * platform specific path separator. - * - * * Will *not* validate the path for invalid characters and semantics. - * * Will *not* look at the scheme of this URI. - * * The result shall *not* be used for display purposes but for accessing a file on disk. - * - * - * The *difference* to `URI#path` is the use of the platform specific separator and the handling - * of UNC paths. See the below sample of a file-uri with an authority (UNC path). - * - * ```ts - const u = URI.parse('file://server/c$/folder/file.txt') - u.authority === 'server' - u.path === '/shares/c$/file.txt' - u.fsPath === '\\server\c$\folder\file.txt' - ``` - * - * Using `URI#path` to read a file (using fs-apis) would not be enough because parts of the path, - * namely the server name, would be missing. Therefore `URI#fsPath` exists - it's sugar to ease working - * with URIs that represent files on disk (`file` scheme). - */ - get fsPath(): string { - // if (this.scheme !== 'file') { - // console.warn(`[UriError] calling fsPath with scheme ${this.scheme}`); - // } - return _makeFsPath(this); - } - - // ---- modify to new ------------------------- - - with(change: { - scheme?: string; - authority?: string | null; - path?: string | null; - query?: string | null; - fragment?: string | null; - }): URI { - if (!change) { - return this; - } - - let { scheme, authority, path, query, fragment } = change; - if (scheme === undefined) { - scheme = this.scheme; - } else if (scheme === null) { - scheme = _empty; - } - if (authority === undefined) { - authority = this.authority; - } else if (authority === null) { - authority = _empty; - } - if (path === undefined) { - path = this.path; - } else if (path === null) { - path = _empty; - } - if (query === undefined) { - query = this.query; - } else if (query === null) { - query = _empty; - } - if (fragment === undefined) { - fragment = this.fragment; - } else if (fragment === null) { - fragment = _empty; - } - - if ( - scheme === this.scheme && - authority === this.authority && - path === this.path && - query === this.query && - fragment === this.fragment - ) { - return this; - } - - // eslint-disable-next-line @typescript-eslint/no-use-before-define - return new _URI(scheme, authority, path, query, fragment); - } - - // ---- parse & validate ------------------------ - - /** - * Creates a new URI from a string, e.g. `http://www.msft.com/some/path`, - * `file:///usr/home`, or `scheme:with/path`. - * - * @param value A string which represents an URI (see `URI#toString`). - * @param {boolean} [_strict=false] - */ - static parse(value: string, _strict = false): URI { - const match = _regexp.exec(value); - if (!match) { - // eslint-disable-next-line @typescript-eslint/no-use-before-define - return new _URI(_empty, _empty, _empty, _empty, _empty); - } - // eslint-disable-next-line @typescript-eslint/no-use-before-define - return new _URI( - match[2] || _empty, - decodeURIComponent(match[4] || _empty), - decodeURIComponent(match[5] || _empty), - decodeURIComponent(match[7] || _empty), - decodeURIComponent(match[9] || _empty), - _strict, - ); - } - - /** - * Creates a new URI from a file system path, e.g. `c:\my\files`, - * `/usr/home`, or `\\server\share\some\path`. - * - * The *difference* between `URI#parse` and `URI#file` is that the latter treats the argument - * as path, not as stringified-uri. E.g. `URI.file(path)` is **not the same as** - * `URI.parse('file://' + path)` because the path might contain characters that are - * interpreted (# and ?). See the following sample: - * ```ts - const good = URI.file('/coding/c#/project1'); - good.scheme === 'file'; - good.path === '/coding/c#/project1'; - good.fragment === ''; - const bad = URI.parse('file://' + '/coding/c#/project1'); - bad.scheme === 'file'; - bad.path === '/coding/c'; // path is now broken - bad.fragment === '/project1'; - ``` - * - * @param path A file system path (see `URI#fsPath`) - */ - static file(path: string): URI { - let authority = _empty; - - // normalize to fwd-slashes on windows, - // on other systems bwd-slashes are valid - // filename character, eg /f\oo/ba\r.txt - if (isWindows) { - path = path.replace(/\\/g, _slash); - } - - // check for authority as used in UNC shares - // or use the path as given - if (path[0] === _slash && path[1] === _slash) { - const idx = path.indexOf(_slash, 2); - if (idx === -1) { - authority = path.substring(2); - path = _slash; - } else { - authority = path.substring(2, idx); - path = path.substring(idx) || _slash; - } - } - - // eslint-disable-next-line @typescript-eslint/no-use-before-define - return new _URI('file', authority, path, _empty, _empty); - } - - static from(components: { - scheme: string; - authority?: string; - path?: string; - query?: string; - fragment?: string; - }): URI { - // eslint-disable-next-line @typescript-eslint/no-use-before-define - return new _URI( - components.scheme, - components.authority, - components.path, - components.query, - components.fragment, - ); - } - - // ---- printing/externalize --------------------------- - - /** - * Creates a string representation for this URI. It's guaranteed that calling - * `URI.parse` with the result of this function creates an URI which is equal - * to this URI. - * - * * The result shall *not* be used for display purposes but for externalization or transport. - * * The result will be encoded using the percentage encoding and encoding happens mostly - * ignore the scheme-specific encoding rules. - * - * @param skipEncoding Do not encode the result, default is `false` - */ - toString(skipEncoding = false): string { - return _asFormatted(this, skipEncoding); - } - - toJSON(): UriComponents { - return this; - } - - static revive(data: UriComponents | URI): URI; - - static revive(data: UriComponents | URI | undefined): URI | undefined; - - static revive(data: UriComponents | URI | null): URI | null; - - static revive(data: UriComponents | URI | undefined | null): URI | undefined | null; - - static revive(data: UriComponents | URI | undefined | null): URI | undefined | null { - if (!data) { - return data; - } - if (data instanceof URI) { - return data; - } - // eslint-disable-next-line @typescript-eslint/no-use-before-define - const result = new _URI(data); - result._formatted = (data).external; - result._fsPath = (data)._sep === _pathSepMarker ? (data).fsPath : null; - return result; - } - - static joinPath(uri: URI, ...pathFragment: string[]): URI { - if (!uri.path) { - throw new Error(`[UriError]: cannot call joinPaths on URI without path`); - } - let newPath: string; - if (isWindows && uri.scheme === 'file') { - newPath = URI.file(pathImport.join(uri.fsPath, ...pathFragment)).path; - } else { - newPath = pathImport.join(uri.path, ...pathFragment); - } - return uri.with({ path: newPath }); - } -} - -export interface UriComponents { - scheme: string; - authority: string; - path: string; - query: string; - fragment: string; -} - -interface UriState extends UriComponents { - $mid: number; - external: string; - fsPath: string; - _sep: 1 | undefined; -} - -class _URI extends URI { - _formatted: string | null = null; - - _fsPath: string | null = null; - - constructor( - schemeOrData: string | UriComponents, - authority?: string, - path?: string, - query?: string, - fragment?: string, - _strict = false, - ) { - super(schemeOrData as string, authority, path, query, fragment, _strict); - this._fsPath = this.fsPath; - } - - get fsPath(): string { - if (!this._fsPath) { - this._fsPath = _makeFsPath(this); - } - return this._fsPath; - } - - toString(skipEncoding = false): string { - if (!skipEncoding) { - if (!this._formatted) { - this._formatted = _asFormatted(this, false); - } - return this._formatted; - } - // we don't cache that - return _asFormatted(this, true); - } - - toJSON(): UriComponents { - const res = { - $mid: 1, - }; - // cached state - if (this._fsPath) { - res.fsPath = this._fsPath; - if (_pathSepMarker) { - res._sep = _pathSepMarker; - } - } - if (this._formatted) { - res.external = this._formatted; - } - // uri components - if (this.path) { - res.path = this.path; - } - if (this.scheme) { - res.scheme = this.scheme; - } - if (this.authority) { - res.authority = this.authority; - } - if (this.query) { - res.query = this.query; - } - if (this.fragment) { - res.fragment = this.fragment; - } - return res; - } -} - -// reserved characters: https://tools.ietf.org/html/rfc3986#section-2.2 -const encodeTable: { [ch: number]: string } = { - [CharCode.Colon]: '%3A', // gen-delims - [CharCode.Slash]: '%2F', - [CharCode.QuestionMark]: '%3F', - [CharCode.Hash]: '%23', - [CharCode.OpenSquareBracket]: '%5B', - [CharCode.CloseSquareBracket]: '%5D', - [CharCode.AtSign]: '%40', - - [CharCode.ExclamationMark]: '%21', // sub-delims - [CharCode.DollarSign]: '%24', - [CharCode.Ampersand]: '%26', - [CharCode.SingleQuote]: '%27', - [CharCode.OpenParen]: '%28', - [CharCode.CloseParen]: '%29', - [CharCode.Asterisk]: '%2A', - [CharCode.Plus]: '%2B', - [CharCode.Comma]: '%2C', - [CharCode.Semicolon]: '%3B', - [CharCode.Equals]: '%3D', - - [CharCode.Space]: '%20', -}; - -function encodeURIComponentFast(uriComponent: string, allowSlash: boolean): string { - let res: string | undefined; - let nativeEncodePos = -1; - - for (let pos = 0; pos < uriComponent.length; pos += 1) { - const code = uriComponent.charCodeAt(pos); - - // unreserved characters: https://tools.ietf.org/html/rfc3986#section-2.3 - if ( - (code >= CharCode.a && code <= CharCode.z) || - (code >= CharCode.A && code <= CharCode.Z) || - (code >= CharCode.Digit0 && code <= CharCode.Digit9) || - code === CharCode.Dash || - code === CharCode.Period || - code === CharCode.Underline || - code === CharCode.Tilde || - (allowSlash && code === CharCode.Slash) - ) { - // check if we are delaying native encode - if (nativeEncodePos !== -1) { - res += encodeURIComponent(uriComponent.substring(nativeEncodePos, pos)); - nativeEncodePos = -1; - } - // check if we write into a new string (by default we try to return the param) - if (res !== undefined) { - res += uriComponent.charAt(pos); - } - } else { - // encoding needed, we need to allocate a new string - if (res === undefined) { - res = uriComponent.substr(0, pos); - } - - // check with default table first - const escaped = encodeTable[code]; - if (escaped !== undefined) { - // check if we are delaying native encode - if (nativeEncodePos !== -1) { - res += encodeURIComponent(uriComponent.substring(nativeEncodePos, pos)); - nativeEncodePos = -1; - } - - // append escaped variant to result - res += escaped; - } else if (nativeEncodePos === -1) { - // use native encode only when needed - nativeEncodePos = pos; - } - } - } - - if (nativeEncodePos !== -1) { - res += encodeURIComponent(uriComponent.substring(nativeEncodePos)); - } - - return res !== undefined ? res : uriComponent; -} - -function encodeURIComponentMinimal(path: string): string { - let res: string | undefined; - for (let pos = 0; pos < path.length; pos += 1) { - const code = path.charCodeAt(pos); - if (code === CharCode.Hash || code === CharCode.QuestionMark) { - if (res === undefined) { - res = path.substr(0, pos); - } - res += encodeTable[code]; - } else if (res !== undefined) { - res += path[pos]; - } - } - return res !== undefined ? res : path; -} - -/** - * Compute `fsPath` for the given uri - */ -function _makeFsPath(uri: URI): string { - let value: string; - if (uri.authority && uri.path.length > 1 && uri.scheme === 'file') { - // unc path: file://shares/c$/far/boo - value = `//${uri.authority}${uri.path}`; - } else if ( - uri.path.charCodeAt(0) === CharCode.Slash && - ((uri.path.charCodeAt(1) >= CharCode.A && uri.path.charCodeAt(1) <= CharCode.Z) || - (uri.path.charCodeAt(1) >= CharCode.a && uri.path.charCodeAt(1) <= CharCode.z)) && - uri.path.charCodeAt(2) === CharCode.Colon - ) { - // windows drive letter: file:///c:/far/boo - value = uri.path[1].toLowerCase() + uri.path.substr(2); - } else { - // other path - value = uri.path; - } - if (isWindows) { - value = value.replace(/\//g, '\\'); - } - return value; -} - -/** - * Create the external version of a uri - */ -function _asFormatted(uri: URI, skipEncoding: boolean): string { - const encoder = !skipEncoding ? encodeURIComponentFast : encodeURIComponentMinimal; - - let res = ''; - let { authority, path } = uri; - const { scheme, query, fragment } = uri; - if (scheme) { - res += scheme; - res += ':'; - } - if (authority || scheme === 'file') { - res += _slash; - res += _slash; - } - if (authority) { - let idx = authority.indexOf('@'); - if (idx !== -1) { - // @ - const userinfo = authority.substr(0, idx); - authority = authority.substr(idx + 1); - idx = userinfo.indexOf(':'); - if (idx === -1) { - res += encoder(userinfo, false); - } else { - // :@ - res += encoder(userinfo.substr(0, idx), false); - res += ':'; - res += encoder(userinfo.substr(idx + 1), false); - } - res += '@'; - } - authority = authority.toLowerCase(); - idx = authority.indexOf(':'); - if (idx === -1) { - res += encoder(authority, false); - } else { - // : - res += encoder(authority.substr(0, idx), false); - res += authority.substr(idx); - } - } - if (path) { - // lower-case windows drive letters in /C:/fff or C:/fff - if (path.length >= 3 && path.charCodeAt(0) === CharCode.Slash && path.charCodeAt(2) === CharCode.Colon) { - const code = path.charCodeAt(1); - if (code >= CharCode.A && code <= CharCode.Z) { - path = `/${String.fromCharCode(code + 32)}:${path.substr(3)}`; // "/c:".length === 3 - } - } else if (path.length >= 2 && path.charCodeAt(1) === CharCode.Colon) { - const code = path.charCodeAt(0); - if (code >= CharCode.A && code <= CharCode.Z) { - path = `${String.fromCharCode(code + 32)}:${path.substr(2)}`; // "/c:".length === 3 - } - } - // encode the rest of the path - res += encoder(path, true); - } - if (query) { - res += '?'; - res += encoder(query, false); - } - if (fragment) { - res += '#'; - res += !skipEncoding ? encodeURIComponentFast(fragment, false) : fragment; - } - return res; -} \ No newline at end of file diff --git a/vscode/src/utils.ts b/vscode/src/utils.ts index 31059f7b..53f85ee7 100644 --- a/vscode/src/utils.ts +++ b/vscode/src/utils.ts @@ -24,6 +24,7 @@ import { promisify } from "util"; import * as crypto from 'crypto'; import { l10n } from './localiser'; import { extConstants } from './constants'; +import { LOGGER } from './logger'; class InputFlowAction { static back = new InputFlowAction(); @@ -342,3 +343,40 @@ export const parseArguments = (input: string): string[] => { return result; } +export namespace FileUtils { + const FILE_SCHEME = "file:"; + + /** + * Converts a given file system path or URI-like string into a {@link vscode.Uri} instance. + * + * This utility attempts to correctly handle both raw file paths and strings that may + * already represent a valid file URI. + * + * ### Behavior + * - If `treatAsUriIfPossible` is **true** and the input starts with the `"file:"` scheme: + * - Attempts to parse the input using `vscode.Uri.parse(path, true)`. + * - If parsing fails, logs the error and throws a generic error. + * - Otherwise: + * - Treats the input as a standard file system path and returns `vscode.Uri.file(path)`. + * + * Any unexpected errors during URI creation are logged through the `LOGGER` and + * rethrown as a generic error. + * + * @param {string} path - The input file system path or URI-like string. + * @param {boolean} [treatAsUriIfPossible=false] - When `true`, attempts to parse the input as a URI + * if it starts with the `"file:"` scheme before falling back to `vscode.Uri.file`. + * @returns {vscode.Uri} The resulting {@link vscode.Uri} object. + * @throws {Error} When both URI parsing and file wrapping fail. + */ + export const toUri = (path: string, treatAsUriIfPossible: boolean = false): vscode.Uri => { + try { + if (treatAsUriIfPossible && path.startsWith(FILE_SCHEME)) { + return vscode.Uri.parse(path, true); + } + return vscode.Uri.file(path); + } catch (err: any) { + LOGGER.log(`Error while parsing uri: ${isError(err) ? err.message : err}`); + throw new Error("Error while parsing URI"); + } + } +} diff --git a/vscode/src/views/TestViewController.ts b/vscode/src/views/TestViewController.ts index d18e8e9a..4bf8cace 100644 --- a/vscode/src/views/TestViewController.ts +++ b/vscode/src/views/TestViewController.ts @@ -26,6 +26,7 @@ import * as path from 'path'; import { asRange, TestCase, TestSuite } from "../lsp/protocol"; import { extCommands, builtInCommands, nbCommands } from "../commands/commands" import { extConstants } from "../constants"; +import { FileUtils } from "../utils"; export class NbTestAdapter { @@ -210,7 +211,7 @@ export class NbTestAdapter { updateTests(suite: TestSuite, testExecution?: boolean): void { let currentSuite = this.testController.items.get(suite.name); - const suiteUri = suite.file ? Uri.parse(suite.file) : undefined; + const suiteUri = suite.file ? FileUtils.toUri(suite.file, true) : undefined; if (!currentSuite || suiteUri && currentSuite.uri?.toString() !== suiteUri.toString()) { currentSuite = this.testController.createTestItem(suite.name, suite.name, suiteUri); this.testController.items.add(currentSuite); @@ -222,7 +223,7 @@ export class NbTestAdapter { const children: TestItem[] = [] suite.tests?.forEach(testCase => { let currentTest = currentSuite?.children.get(testCase.id); - const testUri = testCase.file ? Uri.parse(testCase.file) : undefined; + const testUri = testCase.file ? FileUtils.toUri(testCase.file, true) : undefined; if (currentTest) { if (testUri && currentTest.uri?.toString() !== testUri?.toString()) { currentTest = this.testController.createTestItem(testCase.id, testCase.name, testUri);