diff --git a/src/common/utilities/PathFinderUtil.ts b/src/common/utilities/PathFinderUtil.ts index 227ca1fdf..7085d4ce7 100644 --- a/src/common/utilities/PathFinderUtil.ts +++ b/src/common/utilities/PathFinderUtil.ts @@ -6,13 +6,35 @@ import { WorkspaceFolder } from 'vscode-languageserver/node'; -import { URL } from 'url'; +import { URL, fileURLToPath, pathToFileURL } from 'url'; import * as path from 'path'; import * as fs from 'fs'; import { glob } from 'glob'; const portalConfigFolderName = '.portalconfig'; +/** + * Converts a URI string or file path to a file system path. + * Handles both file:// URIs and plain paths. + */ +function toFileSystemPath(uriOrPath: string): string { + if (uriOrPath.startsWith('file://')) { + return fileURLToPath(uriOrPath); + } + return uriOrPath; +} + +/** + * Converts a file system path or URI to a URL object. + * Handles both file:// URIs and plain paths. + */ +function toFileURL(uriOrPath: string): URL { + if (uriOrPath.startsWith('file://')) { + return new URL(uriOrPath); + } + return pathToFileURL(uriOrPath); +} + export function workspaceContainsPortalConfigFolder(workspaceRootFolders: WorkspaceFolder[] | null): boolean { return workspaceRootFolders?.some(workspaceRootFolder => { return glob.sync('**/website.yml', { cwd: workspaceRootFolder.uri }).length @@ -34,37 +56,37 @@ export function getPortalConfigFolderUrl(workspaceRootFolders: WorkspaceFolder[] */ export function searchPortalConfigFolder(rootFolder: string | null, file: string): URL | null { if (!rootFolder) return null; // if a file is directly opened in VSCode - if (!file.startsWith(rootFolder)) return null; // if 'file' is not a node in the tree with root as 'rootFolder' - if (file === rootFolder) return null; // if we have already traversed all the nodes in the tree under 'rootFolder' - const portalConfigIsSibling = isSibling(file); + + // Normalize both paths to file system paths for consistent comparison + const normalizedRootFolder = toFileSystemPath(rootFolder); + const normalizedFile = toFileSystemPath(file); + + if (!normalizedFile.startsWith(normalizedRootFolder)) return null; // if 'file' is not a node in the tree with root as 'rootFolder' + if (normalizedFile === normalizedRootFolder) return null; // if we have already traversed all the nodes in the tree under 'rootFolder' + const portalConfigIsSibling = isSibling(normalizedFile); if (portalConfigIsSibling) { return portalConfigIsSibling; } - return searchPortalConfigFolder(rootFolder, getParentDirectory(file)); -} - -/** - * returns parent directory/folder of a file -*/ -function getParentDirectory(file: string): string { - return path.dirname(file); + const parentDir = path.dirname(normalizedFile); + // Prevent infinite recursion at filesystem root + if (parentDir === normalizedFile) return null; + return searchPortalConfigFolder(normalizedRootFolder, parentDir); } /** * Checks if the .portalconfig folder lies at the same level as file. * Returns path of .portalconfig folder if above is true else returns null. + * @param file - A normalized file system path (not a URI) */ function isSibling(file: string): URL | null { - const parentDirectory = getParentDirectory(file); + const parentDirectory = path.dirname(file); if (parentDirectory) { - const parentDirectoryUrl = new URL(parentDirectory); - const parentDirectoryContents: string[] = fs.readdirSync(parentDirectoryUrl); + const parentDirectoryContents: string[] = fs.readdirSync(parentDirectory); for (let i = 0; i < parentDirectoryContents.length; i++) { const fileName = parentDirectoryContents[i]; - const filePath = path.join(parentDirectoryUrl.href, fileName); - const fileUrl = new URL(filePath); if (fileName === portalConfigFolderName) { - return fileUrl; + const filePath = path.join(parentDirectory, fileName); + return toFileURL(filePath); } } } diff --git a/src/server/lib/PortalManifestReader.ts b/src/server/lib/PortalManifestReader.ts index 8b854a1c2..b9c2510c5 100644 --- a/src/server/lib/PortalManifestReader.ts +++ b/src/server/lib/PortalManifestReader.ts @@ -6,7 +6,7 @@ import { WorkspaceFolder } from 'vscode-languageserver/node'; -import { URL } from 'url'; +import { URL, fileURLToPath } from 'url'; import * as path from 'path'; import * as fs from 'fs'; import * as YAML from 'yaml'; @@ -24,11 +24,12 @@ export function getMatchedManifestRecords(workspaceRootFolders: WorkspaceFolder[ if (pathOfFileBeingEdited) { const portalConfigFolderUrl = getPortalConfigFolderUrl(workspaceRootFolders, pathOfFileBeingEdited) as URL | null; //https://github.com/Microsoft/TypeScript/issues/11498 if (portalConfigFolderUrl && keyForCompletion) { - const configFiles: string[] = fs.readdirSync(portalConfigFolderUrl); + const portalConfigFolderPath = fileURLToPath(portalConfigFolderUrl); + const configFiles: string[] = fs.readdirSync(portalConfigFolderPath); configFiles.forEach(configFile => { if (configFile.includes(manifest)) { // this is based on the assumption that there will be only one manifest file in portalconfig folder - const manifestFilePath = path.join(portalConfigFolderUrl.href, configFile); - const manifestData = fs.readFileSync(new URL(manifestFilePath), 'utf8'); + const manifestFilePath = path.join(portalConfigFolderPath, configFile); + const manifestData = fs.readFileSync(manifestFilePath, 'utf8'); try { const parsedManifestData = YAML.parse(manifestData); matchedManifestRecords = parsedManifestData[keyForCompletion]; diff --git a/src/server/test/unit/PathFinderUtil.test.ts b/src/server/test/unit/PathFinderUtil.test.ts new file mode 100644 index 000000000..b9e9cd95f --- /dev/null +++ b/src/server/test/unit/PathFinderUtil.test.ts @@ -0,0 +1,140 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import { expect } from 'chai'; +import * as path from 'path'; +import * as fs from 'fs'; +import * as os from 'os'; +import { pathToFileURL } from 'url'; +import { searchPortalConfigFolder, getPortalConfigFolderUrl } from '../../../common/utilities/PathFinderUtil'; + +describe('PathFinderUtil', () => { + let tempDir: string; + let portalConfigDir: string; + let webPagesDir: string; + let homeDir: string; + + before(() => { + // Create a temporary directory structure for testing + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pathfinderutil-test-')); + portalConfigDir = path.join(tempDir, '.portalconfig'); + webPagesDir = path.join(tempDir, 'web-pages'); + homeDir = path.join(webPagesDir, 'home'); + + fs.mkdirSync(portalConfigDir); + fs.mkdirSync(webPagesDir); + fs.mkdirSync(homeDir); + fs.writeFileSync(path.join(homeDir, 'Home.webpage.yml'), 'test content'); + fs.writeFileSync(path.join(portalConfigDir, 'test-manifest.yml'), 'test manifest'); + }); + + after(() => { + // Clean up the temporary directory + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + describe('searchPortalConfigFolder', () => { + it('should return null when rootFolder is null', () => { + const result = searchPortalConfigFolder(null, pathToFileURL(path.join(homeDir, 'Home.webpage.yml')).href); + expect(result).to.be.null; + }); + + it('should return null when file does not start with rootFolder', () => { + const result = searchPortalConfigFolder( + pathToFileURL('/some/other/path').href, + pathToFileURL(path.join(homeDir, 'Home.webpage.yml')).href + ); + expect(result).to.be.null; + }); + + it('should return null when file equals rootFolder', () => { + const result = searchPortalConfigFolder( + pathToFileURL(tempDir).href, + pathToFileURL(tempDir).href + ); + expect(result).to.be.null; + }); + + it('should handle file:// URI format correctly and find .portalconfig', () => { + const rootFolder = pathToFileURL(tempDir).href; + const file = pathToFileURL(path.join(homeDir, 'Home.webpage.yml')).href; + + const result = searchPortalConfigFolder(rootFolder, file); + + expect(result).to.not.be.null; + expect(result?.href).to.include('.portalconfig'); + }); + + it('should handle plain paths without file:// prefix', () => { + // This is the key test for the bug fix - paths without file:// prefix + const rootFolder = tempDir; + const file = path.join(homeDir, 'Home.webpage.yml'); + + const result = searchPortalConfigFolder(rootFolder, file); + + expect(result).to.not.be.null; + expect(result?.href).to.include('.portalconfig'); + }); + + it('should handle mixed formats (rootFolder with file://, file without)', () => { + // This tests the normalization - rootFolder has file:// but file doesn't + const rootFolder = pathToFileURL(tempDir).href; + const file = path.join(homeDir, 'Home.webpage.yml'); + + const result = searchPortalConfigFolder(rootFolder, file); + + expect(result).to.not.be.null; + expect(result?.href).to.include('.portalconfig'); + }); + + it('should handle mixed formats (rootFolder without file://, file with)', () => { + // This tests the normalization - rootFolder doesn't have file:// but file does + const rootFolder = tempDir; + const file = pathToFileURL(path.join(homeDir, 'Home.webpage.yml')).href; + + const result = searchPortalConfigFolder(rootFolder, file); + + expect(result).to.not.be.null; + expect(result?.href).to.include('.portalconfig'); + }); + }); + + describe('getPortalConfigFolderUrl', () => { + it('should return null when workspaceRootFolders is null', () => { + const result = getPortalConfigFolderUrl(null, pathToFileURL(path.join(homeDir, 'Home.webpage.yml')).href); + expect(result).to.be.null; + }); + + it('should return null when workspaceRootFolders is empty', () => { + const result = getPortalConfigFolderUrl([], pathToFileURL(path.join(homeDir, 'Home.webpage.yml')).href); + expect(result).to.be.null; + }); + + it('should search through workspace folders and find .portalconfig with file:// URIs', () => { + const workspaceRootFolders = [ + { uri: pathToFileURL(tempDir).href, name: 'project' } + ]; + const file = pathToFileURL(path.join(homeDir, 'Home.webpage.yml')).href; + + const result = getPortalConfigFolderUrl(workspaceRootFolders, file); + + expect(result).to.not.be.null; + expect(result?.href).to.include('.portalconfig'); + }); + + it('should search through workspace folders and find .portalconfig with plain paths', () => { + // Test with plain paths (no file:// prefix) - this tests the fix + const workspaceRootFolders = [ + { uri: tempDir, name: 'project' } + ]; + const file = path.join(homeDir, 'Home.webpage.yml'); + + const result = getPortalConfigFolderUrl(workspaceRootFolders, file); + + expect(result).to.not.be.null; + expect(result?.href).to.include('.portalconfig'); + }); + }); +});