// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. import type * as fs from 'node:fs'; import * as path from 'node:path'; import type { FileSystemAdapter } from 'fast-glob'; import { Path } from '@rushstack/node-core-library'; interface IVirtualFileSystemEntry { name: string; children?: Set; } interface IReaddirOptions { withFileTypes: true; } /* eslint-disable @rushstack/no-new-null */ type StatCallback = (error: NodeJS.ErrnoException | null, stats: fs.Stats) => void; type ReaddirStringCallback = (error: NodeJS.ErrnoException | null, files: string[]) => void; type ReaddirDirentCallback = (error: NodeJS.ErrnoException | null, files: fs.Dirent[]) => void; /* eslint-enable @rushstack/no-new-null */ const IS_WINDOWS: boolean = process.platform === 'win32'; /** * A filesystem adapter for use with the "fast-glob" package. This adapter uses a static set of paths * to provide a virtual filesystem. * * @remarks This adapter only implements methods required to allow for globbing. This means that it * does not support returning true-to-disk file stats or dirent objects. Instead, the returned file * stats and dirent objects only implement the `isDirectory` and `isFile` methods, which are * required for filesystem traversal performed by the globber. */ export class StaticFileSystemAdapter implements FileSystemAdapter { #directoryMap: Map = new Map(); /** { @inheritdoc fs.lstat } */ public lstat: FileSystemAdapter['lstat'] = ((filePath: string, callback: StatCallback) => { process.nextTick(() => { let result: fs.Stats; try { result = this.lstatSync(filePath); } catch (e) { callback(e, {} as fs.Stats); return; } callback(null, result); }); }) as FileSystemAdapter['lstat']; /** { @inheritdoc fs.lstatSync } */ public lstatSync: FileSystemAdapter['lstatSync'] = ((filePath: string) => { filePath = this.#normalizePath(filePath); const entry: IVirtualFileSystemEntry | undefined = this.#directoryMap.get(filePath); if (!entry) { const error: NodeJS.ErrnoException = new Error(`ENOENT: no such file or directory, stat '${filePath}'`); error.code = 'ENOENT'; error.syscall = 'stat'; error.errno = -4058; error.path = filePath; throw error; } // We should only need to implement these methods for the purposes of fast-glob return { isFile: () => !entry.children, isDirectory: () => !!entry.children, isBlockDevice: () => false, isCharacterDevice: () => false, isSymbolicLink: () => false, isFIFO: () => false, isSocket: () => false }; }) as FileSystemAdapter['lstatSync']; /** { @inheritdoc fs.stat } */ public stat: FileSystemAdapter['stat'] = ((filePath: string, callback: StatCallback) => { this.lstat(filePath, callback); }) as FileSystemAdapter['stat']; /** { @inheritdoc fs.statSync } */ public statSync: FileSystemAdapter['statSync'] = ((filePath: string) => { return this.lstatSync(filePath); }) as FileSystemAdapter['statSync']; /** { @inheritdoc fs.readdir } */ public readdir: FileSystemAdapter['readdir'] = (( filePath: string, optionsOrCallback: IReaddirOptions | ReaddirStringCallback, callback?: ReaddirDirentCallback | ReaddirStringCallback ) => { // Default to no options, which will return a string callback let options: IReaddirOptions | undefined; if (typeof optionsOrCallback === 'object') { options = optionsOrCallback; } else if (typeof optionsOrCallback === 'function') { callback = optionsOrCallback; } // Perform the readdir on the next tick to avoid blocking the event loop process.nextTick(() => { let result: fs.Dirent[] | string[]; try { if (options?.withFileTypes) { result = this.readdirSync(filePath, options) as fs.Dirent[]; } else { result = this.readdirSync(filePath); } } catch (e) { callback!(e, []); return; } // When "withFileTypes" is false or undefined, the callback is expected to return a string array. // Otherwise, we return a fs.Dirent array. if (options?.withFileTypes) { (callback as ReaddirDirentCallback)(null, result as fs.Dirent[]); } else { (callback as ReaddirStringCallback)(null, result as string[]); } }); }) as FileSystemAdapter['readdir']; /** { @inheritdoc fs.readdirSync } */ public readdirSync: FileSystemAdapter['readdirSync'] = ((filePath: string, options?: IReaddirOptions) => { filePath = this.#normalizePath(filePath); const virtualDirectory: IVirtualFileSystemEntry | undefined = this.#directoryMap.get(filePath); if (!virtualDirectory) { // Immitate a missing directory read from fs.readdir const error: NodeJS.ErrnoException = new Error( `ENOENT: no such file or directory, scandir '${filePath}'` ); error.code = 'ENOENT'; error.syscall = 'scandir'; error.errno = -4058; error.path = filePath; throw error; } else if (!virtualDirectory.children) { // Immitate a directory read of a file from fs.readdir const error: NodeJS.ErrnoException = new Error(`ENOTDIR: not a directory, scandir '${filePath}'`); error.code = 'ENOTDIR'; error.syscall = 'scandir'; error.errno = -4052; error.path = filePath; throw error; } // When "withFileTypes" is false or undefined, the method is expected to return a string array. // Otherwise, we return a fs.Dirent array. const result: IVirtualFileSystemEntry[] = Array.from(virtualDirectory.children); if (options?.withFileTypes) { return result.map((entry: IVirtualFileSystemEntry) => { // Partially implement the fs.Dirent interface, only including the properties used by fast-glob return { name: entry.name, isFile: () => !entry.children, isDirectory: () => !!entry.children, isBlockDevice: () => false, isCharacterDevice: () => false, isSymbolicLink: () => false, isFIFO: () => false, isSocket: () => false }; }); } else { return result.map((entry: IVirtualFileSystemEntry) => entry.name); } }) as FileSystemAdapter['readdirSync']; /** * Create a new StaticFileSystemAdapter instance with the provided file paths. */ public constructor(filePaths?: Iterable) { for (const filePath of filePaths || []) { this.addFile(filePath); } } /** * Add a file and it's parent directories to the static virtual filesystem. */ public addFile(filePath: string): void { filePath = this.#normalizePath(filePath); const existingPath: IVirtualFileSystemEntry | undefined = this.#directoryMap.get(filePath); if (!existingPath) { // Set an entry without children for the file. Entries with undefined children are assumed to be files. let childPath: string = filePath; let childEntry: IVirtualFileSystemEntry = { name: path.basename(childPath) }; this.#directoryMap.set(childPath, childEntry); // Loop through the path segments and create entries for each directory, if they don't already exist. // If they do, append to the existing children set and continue. let parentPath: string | undefined; while ((parentPath = path.dirname(childPath)) !== childPath) { const existingParentEntry: IVirtualFileSystemEntry | undefined = this.#directoryMap.get(parentPath); if (existingParentEntry) { // If there is already an existing parent entry, add the child entry to the existing children set // and exit early, since the parent entries already exist. existingParentEntry.children!.add(childEntry); break; } else { // If there is no existing parent entry, create a new entry with the child entry as the only child. const parentEntry: IVirtualFileSystemEntry = { name: path.basename(parentPath), children: new Set([childEntry]) }; this.#directoryMap.set(parentPath, parentEntry); childEntry = parentEntry; childPath = parentPath; } } } } /** * Remove a file from the static virtual filesystem. */ public removeFile(filePath: string): void { filePath = this.#normalizePath(filePath); const existingEntry: IVirtualFileSystemEntry | undefined = this.#directoryMap.get(filePath); if (existingEntry) { // Remove the entry from the map and the parent's children set this.#directoryMap.delete(filePath); this.#directoryMap.get(path.dirname(filePath))!.children!.delete(existingEntry); } } /** * Remove all files from the static virtual filesystem. */ public removeAllFiles(): void { this.#directoryMap.clear(); } #normalizePath(filePath: string): string { // On Windows, normalize to backslashes so that errors have the correct path format return IS_WINDOWS ? Path.convertToBackslashes(filePath) : filePath; } }