Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 40 additions & 18 deletions src/common/utilities/PathFinderUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
}
}
}
Expand Down
9 changes: 5 additions & 4 deletions src/server/lib/PortalManifestReader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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];
Expand Down
140 changes: 140 additions & 0 deletions src/server/test/unit/PathFinderUtil.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
});