import type * as fs from 'fs' import type * as asyncFs from 'fs/promises' import { path } from './path' import { noop } from 'lodash' import { ts } from './typescript' import * as fg from 'fast-glob' import { Writable, Readable } from 'readable-stream' /** * A file system API based on a subset of the `fs` module from Node.js. */ export interface FileSystem { /** * Synchronously deletes the file or directory at the provided path. */ rmSync(path: string, options?: { recursive?: boolean; force?: boolean }): void /** * Synchronously retrieves the status of the file or directory at the provided path. */ statSync(path: string): FileSystem.Stats /** * Synchronously retrieves the status of the file or directory at the provided path. Does * not dereference symbolic links. */ lstatSync(path: string): FileSystem.Stats /** * Synchronously computes the canonical path by resolving `.`, `..` and symbolic links. */ realpathSync(path: string): string /** * Synchronously creates a directory at the provided path. * * If `options.recursive` is `true`, the full path will be created including any missing * parent directories, and the path of the first directory created will be returned. * Otherwise, returns `undefined`. */ mkdirSync(path: string): void mkdirSync(path: string, options: { recursive: true }): string | undefined mkdirSync(path: string, options?: { recursive: boolean }): string | undefined /** * Synchronously renames the file or directory at the old path to the new path. */ renameSync(oldPath: string, newPath: string): void /** * Synchronously reads the contents of the directory at the provided path. * * If `options.withFileTypes` is `true`, returns the contents as an array of `Entry` * objects. Otherwise, returns the contents as an array of strings containing the names * of each entry. */ readdirSync(path: string): string[] readdirSync(path: string, options: { withFileTypes: true }): FileSystem.Entry[] readdirSync(path: string, options?: { withFileTypes: boolean }): string[] | FileSystem.Entry[] /** * Synchronously reads the contents of the file at the provided path. * * If `options.encoding` is provided, the contents will be returned as a string. Otherwise, * the contents will be returned as a `Buffer`. */ readFileSync(path: string, options?: { flag: 'r' }): Buffer readFileSync(path: string, options: { encoding: FileSystem.Encoding; flag?: 'r' }): string readFileSync(path: string, options?: { encoding?: FileSystem.Encoding; flag?: 'r' }): string | Buffer /** * Synchronously writes the provided content to the file at the provided path. */ writeFileSync( path: string, content: string | NodeJS.ArrayBufferView, options?: { encoding: FileSystem.Encoding } ): void /** * Synchronously copies the file at the source path to the destination path. */ copyFileSync(srcPath: string, destPath: string): void /** * Synchronously checks if the file or directory at the provided path is accessible. If it * is accessible, the method does nothing. If it is not accessible, an error is thrown. */ accessSync(path: string): void } export type AsyncFileSystem = { rm(path: fs.PathLike, options?: any): Promise stat(path: fs.PathLike, options?: any): Promise exists(path: fs.PathLike, options?: any): Promise lstat: typeof asyncFs.lstat mkdir: typeof asyncFs.mkdir rename: typeof asyncFs.rename readdir: typeof asyncFs.readdir realpath: typeof asyncFs.realpath readFile: typeof asyncFs.readFile copyFile: typeof asyncFs.copyFile writeFile: typeof asyncFs.writeFile } export namespace FileSystem { export type Entry = { name: string isFile(): boolean isDirectory(): boolean isSymbolicLink(): boolean isCharacterDevice(): boolean isBlockDevice(): boolean isSocket(): boolean isFIFO(): boolean } export type Stats = { isFile(): boolean isDirectory(): boolean isSymbolicLink(): boolean isCharacterDevice(): boolean isBlockDevice(): boolean isSocket(): boolean isFIFO(): boolean } export type Encoding = 'utf-8' | 'binary' /** * Synchronously copies the file or directory at the source path to the destination * path. If the source path is a directory, only its contents will be copied, not the * directory itself. If the source path is a file, the destination path cannot be a * directory. * * If `options.filter` is provided, only entries for which the filter returns `true` * will be copied. */ export function copySync( fs: FileSystem, srcPath: string, destPath: string, options?: { filter(src: string): boolean } ) { if (fs.statSync(srcPath).isDirectory()) { fs.readdirSync(srcPath, { withFileTypes: true }) .filter((file) => options?.filter(file.name) ?? true) .forEach((file) => copySync(fs, path.join(srcPath, file.name), path.join(destPath, file.name), options)) } else { if (!FileSystem.existsSync(fs, path.dirname(destPath))) { fs.mkdirSync(path.dirname(destPath), { recursive: true }) } fs.copyFileSync(srcPath, destPath) } } /** * Synchronously checks if the file or directory at the provided path exists. */ export function existsSync(fs: FileSystem, path: string): boolean { try { fs.accessSync(path) return true } catch { return false } } /* * Ensures that a directory exists synchronously. * If the directory does not exist, it will be created recursively. * * @param fs - The file system object. * @param path - The path of the directory to ensure. */ export function ensureDirSync(fs: FileSystem, path: string) { if (!existsSync(fs, path)) { fs.mkdirSync(path, { recursive: true }) } } /** * Provides an implementation of a write stream for FileSystems that don't * have one natively. Buffers all chunks in memory until finalized. */ export function createWriteStream(fs: FileSystem, path: string): Writable { if ('createWriteStream' in fs && typeof fs.createWriteStream === 'function') { return fs.createWriteStream(path) } const stream = new Writable() // who needs streams when you have infinite memory? const buffer: Uint8Array[] = [] stream._write = (chunk, _encoding, callback) => { buffer.push(chunk) callback() } stream._writev = (chunks, callback) => { for (const { chunk, encoding } of chunks) { stream._write(chunk, encoding, noop) } callback() } stream._final = (callback) => { const totalSize = buffer.reduce((result, array) => result + array.length, 0) const merged = new Uint8Array(totalSize) let offset = 0 buffer.forEach((array) => { merged.set(array, offset) offset += array.length }) fs.writeFileSync(path, Buffer.from(merged.buffer)) buffer.length = 0 callback() } return stream } /** * Provides an implementation of a read stream for FileSystems that don't * have one natively. Reads the entire content as a single chunk. */ export function createReadStream(fs: FileSystem, path: string): Readable { if ('createReadStream' in fs && typeof fs.createReadStream === 'function') { return fs.createReadStream(path) } const stream = new Readable() stream._read = () => { stream.push(fs.readFileSync(path)) stream.push(null) } return stream } } export class TsMorphFileSystemWrapper implements ts.FileSystemHost { static ENOENT = 'ENOENT' constructor(private readonly fs: FileSystem) {} deleteSync(path: string): void { try { this.fs.rmSync(path, { recursive: true }) } catch (err) { // It is required to throw this specific subclass of error in order for ts-morph to work properly throw this.#getFileNotFoundErrorIfNecessary(err, path) } } readFileSync(path: string, encoding: FileSystem.Encoding = 'utf-8'): string { try { return this.fs.readFileSync(path, { encoding }) } catch (err) { // It is required to throw this specific subclass of error in order for ts-morph to work properly throw this.#getFileNotFoundErrorIfNecessary(err, path) } } writeFileSync(path: string, text: string): void { this.fs.writeFileSync(path, text) } mkdirSync(path: string): void { this.fs.mkdirSync(path, { recursive: true }) } moveSync(srcPath: string, destPath: string): void { this.fs.renameSync(srcPath, destPath) } copySync(srcPath: string, destPath: string): void { this.fs.copyFileSync(srcPath, destPath) } fileExistsSync(path: string): boolean { return FileSystem.existsSync(this.fs, path) } directoryExistsSync(path: string): boolean { return this.fileExistsSync(path) } readDirSync(dir: string): ts.RuntimeDirEntry[] { try { const entries = this.fs.readdirSync(dir, { withFileTypes: true }).map((entry) => { return { name: entry.name, isFile: entry.isFile(), isDirectory: entry.isDirectory(), isSymlink: entry.isSymbolicLink(), } }) for (const entry of entries) { entry.name = path.join(dir, entry.name) if (entry.isSymlink) { try { const info = this.fs.statSync(entry.name) if (info != null) { entry.isDirectory = info.isDirectory() entry.isFile = info.isFile() } } catch { // Ignore } } } return entries } catch (err) { // It is required to throw this specific subclass of error in order for ts-morph to work properly throw this.#getDirectoryNotFoundErrorIfNecessary(err, dir) } } globSync(patterns: string[], opts?: fg.Options): string[] { return fg.sync( patterns.map((pattern) => (this.fileExistsSync(pattern) ? fg.convertPathToPattern(pattern) : pattern)), { absolute: true, cwd: this.getCurrentDirectory(), ...opts, fs: { readdir: this.fs.readdirSync.bind(this.fs), readdirSync: this.fs.readdirSync.bind(this.fs), stat: this.fs.statSync.bind(this.fs), statSync: this.fs.statSync.bind(this.fs), lstat: this.fs.lstatSync.bind(this.fs), lstatSync: this.fs.lstatSync.bind(this.fs), } as unknown as fg.FileSystemAdapter, // TODO: There are probably some compatibility issues here that will cause subtle bugs } ) } realpathSync(path: string): string { return this.fs.realpathSync(path) } getCurrentDirectory(): string { return path.resolve() } isCaseSensitive(): boolean { return false // TODO: Should we try to do something more sophisticated here? } async delete(path: string): Promise { this.deleteSync(path) } async readFile(path: string, encoding?: FileSystem.Encoding): Promise { return this.readFileSync(path, encoding) } async writeFile(path: string, text: string): Promise { this.writeFileSync(path, text) } async mkdir(path: string): Promise { this.mkdirSync(path) } async move(srcPath: string, destPath: string): Promise { this.moveSync(srcPath, destPath) } async copy(srcPath: string, destPath: string): Promise { this.copySync(srcPath, destPath) } async fileExists(path: string): Promise { return this.fileExistsSync(path) } async directoryExists(path: string): Promise { return this.directoryExistsSync(path) } async glob(patterns: string[]): Promise { return this.globSync(patterns) } /** * Returns a specific subclass of error when a directory is not found which is * required for ts-morph to work properly. */ #getDirectoryNotFoundErrorIfNecessary(err: any, path: string) { return this.#isNotExistsError(err) ? new ts.DirectoryNotFoundError(path) : err } /** * Returns a specific subclass of error when a file is not found which is required * for ts-morph to work properly. */ #getFileNotFoundErrorIfNecessary(err: any, path: string) { return this.#isNotExistsError(err) ? new ts.FileNotFoundError(path) : err } /** * Checks if the error is a "not exists" error. This is copied directly from one * of ts-morph's internal `FileSystemHost` implementations. */ #isNotExistsError(err: any) { return ( (err != null && err.code === TsMorphFileSystemWrapper.ENOENT) || (err != null && err?.constructor?.name === 'NotFound') ) } }