diff --git a/src/client/common/platform/fileSystem.ts b/src/client/common/platform/fileSystem.ts index fadeccaaf929..4e05655583f1 100644 --- a/src/client/common/platform/fileSystem.ts +++ b/src/client/common/platform/fileSystem.ts @@ -3,22 +3,25 @@ 'use strict'; import { createHash } from 'crypto'; -import * as fileSystem from 'fs'; import * as fs from 'fs-extra'; import * as glob from 'glob'; import { inject, injectable } from 'inversify'; import { promisify } from 'util'; +import * as vscode from 'vscode'; import { createDeferred } from '../utils/async'; import { noop } from '../utils/misc'; +import { isFileNotFoundError } from './errors'; import { FileSystemPaths, FileSystemPathUtils } from './fs-paths'; import { TemporaryFileSystem } from './fs-temp'; // prettier-ignore import { FileStat, FileType, - IFileSystem, IFileSystemPaths, IPlatformService, - TemporaryFile + IFileSystem, IFileSystemPaths, IPlatformService, IRawFileSystem, + ReadStream, TemporaryFile, WriteStream } from './types'; +const ENCODING: string = 'utf8'; + const globAsync = promisify(glob); // This helper function determines the file type of the given stats @@ -38,36 +41,6 @@ function convertFileType(stat: fs.Stats): FileType { } } -async function getFileType(filename: string): Promise { - let stat: fs.Stats; - try { - // Note that we used to use stat() here instead of lstat(). - // This shouldn't matter because the only consumers were - // internal methods that have been updated appropriately. - stat = await fs.lstat(filename); - } catch { - return FileType.Unknown; - } - if (!stat.isSymbolicLink()) { - return convertFileType(stat); - } - - // For symlinks we emulate the behavior of the vscode.workspace.fs API. - // See: https://code.visualstudio.com/api/references/vscode-api#FileType - try { - stat = await fs.stat(filename); - } catch { - return FileType.SymbolicLink; - } - if (stat.isFile()) { - return FileType.SymbolicLink | FileType.File; - } else if (stat.isDirectory()) { - return FileType.SymbolicLink | FileType.Directory; - } else { - return FileType.SymbolicLink; - } -} - export function convertStat(old: fs.Stats, filetype: FileType): FileStat { return { type: filetype, @@ -81,8 +54,231 @@ export function convertStat(old: fs.Stats, filetype: FileType): FileStat { }; } +// prettier-ignore +function filterByFileType( + files: [string, FileType][], + fileType: FileType +): [string, FileType][] { + // We preserve the pre-existing behavior of following symlinks. + if (fileType === FileType.Unknown) { + // FileType.Unknown == 0 so we can't just use bitwise + // operations blindly here. + return files.filter(([_file, ft]) => { + return ft === FileType.Unknown || ft === (FileType.SymbolicLink & FileType.Unknown); + }); + } else { + return files.filter(([_file, ft]) => (ft & fileType) > 0); + } +} + +//========================================== +// "raw" filesystem + +// This is the parts of the vscode.workspace.fs API that we use here. +// See: https://code.visualstudio.com/api/references/vscode-api#FileSystem +// Note that we have used all the API functions *except* "rename()". +interface IVSCodeFileSystemAPI { + stat(uri: vscode.Uri): Thenable; +} + +// This is the parts of the 'fs-extra' module that we use in RawFileSystem. +interface IRawFSExtra { + stat(filename: string): Promise; + lstat(filename: string): Promise; + readdir(dirname: string): Promise; + readFile(filename: string): Promise; + readFile(filename: string, encoding: string): Promise; + mkdirp(dirname: string): Promise; + chmod(filePath: string, mode: string | number): Promise; + rename(src: string, tgt: string): Promise; + writeFile(filename: string, data: {}, options: {}): Promise; + appendFile(filename: string, data: {}): Promise; + unlink(filename: string): Promise; + rmdir(dirname: string): Promise; + + // non-async + readFileSync(path: string, encoding: string): string; + createReadStream(filename: string): ReadStream; + createWriteStream(filename: string): WriteStream; +} + +interface IRawPath { + join(...paths: string[]): string; +} + +// Later we will drop "FileSystem", switching usage to +// "FileSystemUtils" and then rename "RawFileSystem" to "FileSystem". + +// The low-level filesystem operations used by the extension. +export class RawFileSystem implements IRawFileSystem { + // prettier-ignore + constructor( + protected readonly paths: IRawPath, + protected readonly vscfs: IVSCodeFileSystemAPI, + protected readonly fsExtra: IRawFSExtra + ) { } + + // Create a new object using common-case default values. + // prettier-ignore + public static withDefaults( + paths?: IRawPath, + vscfs?: IVSCodeFileSystemAPI, + fsExtra?: IRawFSExtra + ): RawFileSystem{ + // prettier-ignore + return new RawFileSystem( + paths || FileSystemPaths.withDefaults(), + vscfs || vscode.workspace.fs, + fsExtra || fs + ); + } + + public async stat(filename: string): Promise { + // Note that, prior to the November release of VS Code, + // stat.ctime was always 0. + // See: https://github.com/microsoft/vscode/issues/84525 + const uri = vscode.Uri.file(filename); + return this.vscfs.stat(uri); + } + + public async lstat(filename: string): Promise { + const stat = await this.fsExtra.lstat(filename); + // Note that, unlike stat(), lstat() does not include the type + // of the symlink's target. + const fileType = convertFileType(stat); + return convertStat(stat, fileType); + } + + public async chmod(filename: string, mode: string | number): Promise { + return this.fsExtra.chmod(filename, mode); + } + + public async move(src: string, tgt: string) { + await this.fsExtra.rename(src, tgt); + } + + public async readData(filename: string): Promise { + return this.fsExtra.readFile(filename); + } + + public async readText(filename: string): Promise { + return this.fsExtra.readFile(filename, ENCODING); + } + + public async writeText(filename: string, text: string): Promise { + await this.fsExtra.writeFile(filename, text, { encoding: ENCODING }); + } + + public async appendText(filename: string, text: string): Promise { + return this.fsExtra.appendFile(filename, text); + } + + public async copyFile(src: string, dest: string): Promise { + const deferred = createDeferred(); + // prettier-ignore + const rs = this.fsExtra.createReadStream(src) + .on('error', err => { + deferred.reject(err); + }); + // prettier-ignore + const ws = this.fsExtra.createWriteStream(dest) + .on('error', err => { + deferred.reject(err); + }) + .on('close', () => { + deferred.resolve(); + }); + rs.pipe(ws); + return deferred.promise; + } + + public async rmfile(filename: string): Promise { + return this.fsExtra.unlink(filename); + } + + public async rmtree(dirname: string): Promise { + return this.fsExtra.rmdir(dirname); + } + + public async mkdirp(dirname: string): Promise { + return this.fsExtra.mkdirp(dirname); + } + + public async listdir(dirname: string): Promise<[string, FileType][]> { + const files = await this.fsExtra.readdir(dirname); + const promises = files.map(async basename => { + const filename = this.paths.join(dirname, basename); + // Note that this follows symlinks (while still preserving + // the Symlink flag). + const fileType = await this.getFileType(filename); + return [filename, fileType] as [string, FileType]; + }); + return Promise.all(promises); + } + + //**************************** + // non-async + + public readTextSync(filename: string): string { + return this.fsExtra.readFileSync(filename, ENCODING); + } + + public createReadStream(filename: string): ReadStream { + return this.fsExtra.createReadStream(filename); + } + + public createWriteStream(filename: string): WriteStream { + return this.fsExtra.createWriteStream(filename); + } + + //**************************** + // internal + + private async getFileType(filename: string): Promise { + let stat: fs.Stats; + try { + // Note that we used to use stat() here instead of lstat(). + // This shouldn't matter because the only consumers were + // internal methods that have been updated appropriately. + stat = await this.fsExtra.lstat(filename); + } catch (err) { + if (isFileNotFoundError(err)) { + return FileType.Unknown; + } + throw err; + } + if (!stat.isSymbolicLink()) { + return convertFileType(stat); + } + + // For symlinks we emulate the behavior of the vscode.workspace.fs API. + // See: https://code.visualstudio.com/api/references/vscode-api#FileType + try { + stat = await this.fsExtra.stat(filename); + } catch (err) { + if (isFileNotFoundError(err)) { + return FileType.SymbolicLink; + } + throw err; + } + if (stat.isFile()) { + return FileType.SymbolicLink | FileType.File; + } else if (stat.isDirectory()) { + return FileType.SymbolicLink | FileType.Directory; + } else { + return FileType.SymbolicLink; + } + } +} + +//========================================== +// filesystem "utils" (& legacy aliases) + @injectable() export class FileSystem implements IFileSystem { + // We expose this for the sake of functional tests that do not have + // access to the actual "vscode" namespace. + protected raw: RawFileSystem; private readonly paths: IFileSystemPaths; private readonly pathUtils: FileSystemPathUtils; private readonly tmp: TemporaryFileSystem; @@ -96,6 +292,7 @@ export class FileSystem implements IFileSystem { ); this.pathUtils = FileSystemPathUtils.withDefaults(this.paths); this.tmp = TemporaryFileSystem.withDefaults(); + this.raw = RawFileSystem.withDefaults(this.paths); } //================================= @@ -112,183 +309,139 @@ export class FileSystem implements IFileSystem { //================================= // "raw" operations - public async stat(filePath: string): Promise { - // Do not import vscode directly, as this isn't available in the Debugger Context. - // If stat is used in debugger context, it will fail, however theres a separate PR that will resolve this. - // tslint:disable-next-line: no-require-imports - const vscode = require('vscode'); - // Note that, prior to the November release of VS Code, - // stat.ctime was always 0. - // See: https://github.com/microsoft/vscode/issues/84525 - return vscode.workspace.fs.stat(vscode.Uri.file(filePath)); + public async stat(filename: string): Promise { + return this.raw.stat(filename); } + public async lstat(filename: string): Promise { - const stat = await fs.lstat(filename); - // Note that, unlike stat(), lstat() does not include the type - // of the symlink's target. - const fileType = convertFileType(stat); - return convertStat(stat, fileType); + return this.raw.lstat(filename); } - // Return the UTF8-decoded text of the file. - public readFile(filePath: string): Promise { - return fs.readFile(filePath, 'utf8'); + public async readFile(filePath: string): Promise { + return this.raw.readText(filePath); } public readFileSync(filePath: string): string { - return fs.readFileSync(filePath, 'utf8'); + return this.raw.readTextSync(filePath); } - public readData(filePath: string): Promise { - return fs.readFile(filePath); + public async readData(filePath: string): Promise { + return this.raw.readData(filePath); } - public async writeFile(filePath: string, data: {}, options: string | fs.WriteFileOptions = { encoding: 'utf8' }): Promise { - await fs.writeFile(filePath, data, options); + public async writeFile(filePath: string, text: string, _options: string | fs.WriteFileOptions = { encoding: 'utf8' }): Promise { + // tslint:disable-next-line:no-suspicious-comment + // TODO (GH-8542) For now we ignore the options, since all call + // sites already match the defaults. Later we will fix the call + // sites. + return this.raw.writeText(filePath, text); } - public createDirectory(directoryPath: string): Promise { - return fs.mkdirp(directoryPath); + public async createDirectory(directoryPath: string): Promise { + return this.raw.mkdirp(directoryPath); } - public deleteDirectory(directoryPath: string): Promise { - const deferred = createDeferred(); - fs.rmdir(directoryPath, err => (err ? deferred.reject(err) : deferred.resolve())); - return deferred.promise; + public async deleteDirectory(directoryPath: string): Promise { + return this.raw.rmtree(directoryPath); } public async listdir(dirname: string): Promise<[string, FileType][]> { - const files = await fs.readdir(dirname); - const promises = files.map(async basename => { - const filename = this.paths.join(dirname, basename); - const fileType = await getFileType(filename); - return [filename, fileType] as [string, FileType]; - }); - return Promise.all(promises); + // prettier-ignore + return this.raw.listdir(dirname) + .catch(async err => { + // We're only preserving pre-existng behavior here... + if (!(await this.pathExists(dirname))) { + return []; + } + throw err; // re-throw + }); } - public appendFile(filename: string, data: {}): Promise { - return fs.appendFile(filename, data); + public async appendFile(filename: string, text: string): Promise { + return this.raw.appendText(filename, text); } - public copyFile(src: string, dest: string): Promise { - const deferred = createDeferred(); - const rs = fs.createReadStream(src).on('error', err => { - deferred.reject(err); - }); - const ws = fs - .createWriteStream(dest) - .on('error', err => { - deferred.reject(err); - }) - .on('close', () => { - deferred.resolve(); - }); - rs.pipe(ws); - return deferred.promise; + public async copyFile(src: string, dest: string): Promise { + return this.raw.copyFile(src, dest); } - public deleteFile(filename: string): Promise { - const deferred = createDeferred(); - fs.unlink(filename, err => (err ? deferred.reject(err) : deferred.resolve())); - return deferred.promise; + public async deleteFile(filename: string): Promise { + return this.raw.rmfile(filename); } - public chmod(filePath: string, mode: string | number): Promise { - return new Promise((resolve, reject) => { - fileSystem.chmod(filePath, mode, (err: NodeJS.ErrnoException | null) => { - if (err) { - return reject(err); - } - resolve(); - }); - }); + public async chmod(filePath: string, mode: string | number): Promise { + return this.raw.chmod(filePath, mode); } public async move(src: string, tgt: string) { - await fs.rename(src, tgt); + await this.raw.move(src, tgt); } - public createReadStream(filePath: string): fileSystem.ReadStream { - return fileSystem.createReadStream(filePath); + public createReadStream(filePath: string): ReadStream { + return this.raw.createReadStream(filePath); } - public createWriteStream(filePath: string): fileSystem.WriteStream { - return fileSystem.createWriteStream(filePath); + public createWriteStream(filePath: string): WriteStream { + return this.raw.createWriteStream(filePath); } //================================= // utils - public objectExists(filePath: string, statCheck: (s: fs.Stats) => boolean): Promise { - return new Promise(resolve => { + // prettier-ignore + public async pathExists( + filename: string, + fileType?: FileType + ): Promise { + let stat: FileStat; + try { // Note that we are using stat() rather than lstat(). This // means that any symlinks are getting resolved. - fs.stat(filePath, (error, stats) => { - if (error) { - return resolve(false); - } - return resolve(statCheck(stats)); - }); - }); + stat = await this.raw.stat(filename); + } catch (err) { + if (isFileNotFoundError(err)) { + return false; + } + throw err; + } + + if (fileType === undefined) { + return true; + } + if (fileType === FileType.Unknown) { + // FileType.Unknown == 0, hence do not use bitwise operations. + return stat.type === FileType.Unknown; + } + return (stat.type & fileType) === fileType; } - public fileExists(filePath: string): Promise { - return this.objectExists(filePath, stats => stats.isFile()); + public async fileExists(filename: string): Promise { + return this.pathExists(filename, FileType.File); } public fileExistsSync(filePath: string): boolean { return fs.existsSync(filePath); } - public directoryExists(filePath: string): Promise { - return this.objectExists(filePath, stats => stats.isDirectory()); + public async directoryExists(dirname: string): Promise { + return this.pathExists(dirname, FileType.Directory); } public async getSubDirectories(dirname: string): Promise { - let files: [string, FileType][]; - try { - files = await this.listdir(dirname); - } catch { - // We're only preserving pre-existng behavior here... - return []; - } - return files - .filter(([_file, fileType]) => { - // We preserve the pre-existing behavior of following - // symlinks. - return (fileType & FileType.Directory) > 0; - }) - .map(([filename, _ft]) => filename); + // prettier-ignore + return filterByFileType( + (await this.listdir(dirname)), + FileType.Directory + ).map(([filename, _fileType]) => filename); } public async getFiles(dirname: string): Promise { - let files: [string, FileType][]; - try { - files = await this.listdir(dirname); - } catch (err) { - // This matches what getSubDirectories() does. - if (!(await fs.pathExists(dirname))) { - return []; - } - throw err; // re-throw - } - return files - .filter(([_file, fileType]) => { - // We preserve the pre-existing behavior of following - // symlinks. - return (fileType & FileType.File) > 0; - }) - .map(([filename, _ft]) => filename); - } - - public getFileHash(filePath: string): Promise { - return new Promise((resolve, reject) => { - fs.lstat(filePath, (err, stats) => { - if (err) { - reject(err); - } else { - const actual = createHash('sha512') - .update(`${stats.ctimeMs}-${stats.mtimeMs}`) - .digest('hex'); - resolve(actual); - } - }); - }); + // prettier-ignore + return filterByFileType( + (await this.listdir(dirname)), + FileType.File + ).map(([filename, _fileType]) => filename); + } + + public async getFileHash(filename: string): Promise { + // The reason for lstat rather than stat is not clear... + const stat = await this.raw.lstat(filename); + const data = `${stat.ctime}-${stat.mtime}`; + return getHashString(data); } public async search(globPattern: string, cwd?: string): Promise { @@ -322,3 +475,12 @@ export class FileSystem implements IFileSystem { }); } } + +// We *could* use ICryptoUtils, but it's a bit overkill, issue tracked +// in https://github.com/microsoft/vscode-python/issues/8438. +function getHashString(data: string): string { + // prettier-ignore + const hash = createHash('sha512') + .update(data); + return hash.digest('hex'); +} diff --git a/src/client/common/platform/types.ts b/src/client/common/platform/types.ts index e014aaf9f2fd..3776253b182e 100644 --- a/src/client/common/platform/types.ts +++ b/src/client/common/platform/types.ts @@ -91,8 +91,57 @@ export interface IFileSystemPathUtils { export import FileType = vscode.FileType; export import FileStat = vscode.FileStat; +export type ReadStream = fs.ReadStream; export type WriteStream = fs.WriteStream; +// The low-level filesystem operations on which the extension depends. +export interface IRawFileSystem { + // Get information about a file (resolve symlinks). + stat(filename: string): Promise; + // Get information about a file (do not resolve synlinks). + lstat(filename: string): Promise; + // Change a file's permissions. + chmod(filename: string, mode: string | number): Promise; + // Move the file to a different location (and/or rename it). + move(src: string, tgt: string): Promise; + + //*********************** + // files + + // Return the raw bytes of the given file. + readData(filename: string): Promise; + // Return the text of the given file (decoded from UTF-8). + readText(filename: string): Promise; + // Write the given text to the file (UTF-8 encoded). + writeText(filename: string, data: {}): Promise; + // Write the given text to the end of the file (UTF-8 encoded). + appendText(filename: string, text: string): Promise; + // Copy a file. + copyFile(src: string, dest: string): Promise; + // Delete a file. + rmfile(filename: string): Promise; + + //*********************** + // directories + + // Create the directory and any missing parent directories. + mkdirp(dirname: string): Promise; + // Delete the directory and everything in it. + rmtree(dirname: string): Promise; + // Return the contents of the directory. + listdir(dirname: string): Promise<[string, FileType][]>; + + //*********************** + // not async + + // Return the text of the given file (decoded from UTF-8). + readTextSync(filename: string): string; + // Create a streaming wrappr around an open file (for reading). + createReadStream(filename: string): ReadStream; + // Create a streaming wrappr around an open file (for writing). + createWriteStream(filename: string): WriteStream; +} + export const IFileSystem = Symbol('IFileSystem'); export interface IFileSystem { // path-related @@ -106,8 +155,8 @@ export interface IFileSystem { listdir(dirname: string): Promise<[string, FileType][]>; readFile(filePath: string): Promise; readData(filePath: string): Promise; - writeFile(filePath: string, data: {}, options?: string | fsextra.WriteFileOptions): Promise; - appendFile(filename: string, data: {}): Promise; + writeFile(filePath: string, text: string | Buffer, options?: string | fsextra.WriteFileOptions): Promise; + appendFile(filename: string, text: string | Buffer): Promise; copyFile(src: string, dest: string): Promise; deleteFile(filename: string): Promise; chmod(path: string, mode: string | number): Promise; diff --git a/src/test/common/platform/filesystem.functional.test.ts b/src/test/common/platform/filesystem.functional.test.ts index 463d052b34b0..27c101423384 100644 --- a/src/test/common/platform/filesystem.functional.test.ts +++ b/src/test/common/platform/filesystem.functional.test.ts @@ -6,7 +6,7 @@ import { expect, use } from 'chai'; import * as fs from 'fs-extra'; import * as path from 'path'; -import { convertStat, FileSystem } from '../../../client/common/platform/fileSystem'; +import { convertStat, FileSystem, RawFileSystem } from '../../../client/common/platform/fileSystem'; import { FileSystemPaths, FileSystemPathUtils } from '../../../client/common/platform/fs-paths'; import { PlatformService } from '../../../client/common/platform/platformService'; import { FileType } from '../../../client/common/platform/types'; @@ -23,6 +23,774 @@ const assertArrays = require('chai-arrays'); use(require('chai-as-promised')); use(assertArrays); +suite('FileSystem - raw', () => { + let fileSystem: RawFileSystem; + let fix: FSFixture; + setup(async () => { + fileSystem = RawFileSystem.withDefaults(); + fix = new FSFixture(); + + await assertDoesNotExist(DOES_NOT_EXIST); + }); + teardown(async () => { + await fix.cleanUp(); + await fix.ensureDeleted(DOES_NOT_EXIST); + }); + + suite('lstat', () => { + test('for symlinks, gives the link info', async function() { + if (!SUPPORTS_SYMLINKS) { + // tslint:disable-next-line:no-invalid-this + this.skip(); + } + const filename = await fix.createFile('x/y/z/spam.py', '...'); + const symlink = await fix.createSymlink('x/y/z/eggs.py', filename); + // prettier-ignore + const expected = convertStat( + await fs.lstat(symlink), + FileType.SymbolicLink + ); + + const stat = await fileSystem.lstat(symlink); + + expect(stat).to.deep.equal(expected); + }); + + test('for normal files, gives the file info', async () => { + const filename = await fix.createFile('x/y/z/spam.py', '...'); + // Ideally we would compare to the result of + // fileSystem.stat(). However, we do not have access + // to the VS Code API here. + // prettier-ignore + const expected = convertStat( + await fs.lstat(filename), + FileType.File + ); + + const stat = await fileSystem.lstat(filename); + + expect(stat).to.deep.equal(expected); + }); + + test('fails if the file does not exist', async () => { + const promise = fileSystem.lstat(DOES_NOT_EXIST); + + await expect(promise).to.eventually.be.rejected; + }); + }); + + suite('chmod (non-Windows)', () => { + suiteSetup(function() { + // On Windows, chmod won't have any effect on the file itself. + if (WINDOWS) { + // tslint:disable-next-line:no-invalid-this + this.skip(); + } + }); + + async function checkMode(filename: string, expected: number) { + const stat = await fs.stat(filename); + expect(stat.mode & 0o777).to.equal(expected); + } + + test('the file mode gets updated (string)', async () => { + const filename = await fix.createFile('spam.py', '...'); + await fs.chmod(filename, 0o644); + + await fileSystem.chmod(filename, '755'); + + await checkMode(filename, 0o755); + }); + + test('the file mode gets updated (number)', async () => { + const filename = await fix.createFile('spam.py', '...'); + await fs.chmod(filename, 0o644); + + await fileSystem.chmod(filename, 0o755); + + await checkMode(filename, 0o755); + }); + + test('the file mode gets updated for a directory', async () => { + const dirname = await fix.createDirectory('spam'); + await fs.chmod(dirname, 0o755); + + await fileSystem.chmod(dirname, 0o700); + + await checkMode(dirname, 0o700); + }); + + test('nothing happens if the file mode already matches', async () => { + const filename = await fix.createFile('spam.py', '...'); + await fs.chmod(filename, 0o644); + + await fileSystem.chmod(filename, 0o644); + + await checkMode(filename, 0o644); + }); + + test('fails if the file does not exist', async () => { + const promise = fileSystem.chmod(DOES_NOT_EXIST, 0o755); + + await expect(promise).to.eventually.be.rejected; + }); + }); + + suite('move', () => { + test('rename file', async () => { + const source = await fix.createFile('spam.py', ''); + const target = await fix.resolve('eggs-txt'); + await assertDoesNotExist(target); + + await fileSystem.move(source, target); + + await assertExists(target); + const text = await fs.readFile(target, 'utf8'); + expect(text).to.equal(''); + await assertDoesNotExist(source); + }); + + test('rename directory', async () => { + const source = await fix.createDirectory('spam'); + await fix.createFile('spam/data.json', ''); + const target = await fix.resolve('eggs'); + const filename = await fix.resolve('eggs/data.json', false); + await assertDoesNotExist(target); + + await fileSystem.move(source, target); + + await assertExists(filename); + const text = await fs.readFile(filename, 'utf8'); + expect(text).to.equal(''); + await assertDoesNotExist(source); + }); + + test('rename symlink', async function() { + if (!SUPPORTS_SYMLINKS) { + // tslint:disable-next-line:no-invalid-this + this.skip(); + } + const filename = await fix.createFile('spam.py'); + const symlink = await fix.createSymlink('spam.lnk', filename); + const target = await fix.resolve('eggs'); + await assertDoesNotExist(target); + + await fileSystem.move(symlink, target); + + await assertExists(target); + const linked = await fs.readlink(target); + expect(linked).to.equal(filename); + await assertDoesNotExist(symlink); + }); + + test('move file', async () => { + const source = await fix.createFile('spam.py', ''); + await fix.createDirectory('eggs'); + const target = await fix.resolve('eggs/spam.py'); + await assertDoesNotExist(target); + + await fileSystem.move(source, target); + + await assertExists(target); + const text = await fs.readFile(target, 'utf8'); + expect(text).to.equal(''); + await assertDoesNotExist(source); + }); + + test('move directory', async () => { + const source = await fix.createDirectory('spam/spam/spam/eggs/spam'); + await fix.createFile('spam/spam/spam/eggs/spam/data.json', ''); + await fix.createDirectory('spam/spam/spam/hash'); + const target = await fix.resolve('spam/spam/spam/hash/spam'); + const filename = await fix.resolve('spam/spam/spam/hash/spam/data.json', false); + await assertDoesNotExist(target); + + await fileSystem.move(source, target); + + await assertExists(filename); + const text = await fs.readFile(filename, 'utf8'); + expect(text).to.equal(''); + await assertDoesNotExist(source); + }); + + test('move symlink', async function() { + if (!SUPPORTS_SYMLINKS) { + // tslint:disable-next-line:no-invalid-this + this.skip(); + } + const filename = await fix.createFile('spam.py'); + const symlink = await fix.createSymlink('w/spam.lnk', filename); + const target = await fix.resolve('x/spam.lnk'); + await assertDoesNotExist(target); + + await fileSystem.move(symlink, target); + + await assertExists(target); + const linked = await fs.readlink(target); + expect(linked).to.equal(filename); + await assertDoesNotExist(symlink); + }); + + test('file target already exists', async () => { + const source = await fix.createFile('spam.py', ''); + const target = await fix.createFile('eggs-txt', ''); + + await fileSystem.move(source, target); + + await assertDoesNotExist(source); + await assertExists(target); + const text2 = await fs.readFile(target, 'utf8'); + expect(text2).to.equal(''); + }); + + test('directory target already exists', async () => { + const source = await fix.createDirectory('spam'); + const file3 = await fix.createFile('spam/data.json', ''); + const target = await fix.createDirectory('eggs'); + const file1 = await fix.createFile('eggs/spam.py', ''); + const file2 = await fix.createFile('eggs/data.json', ''); + + const promise = fileSystem.move(source, target); + + await expect(promise).to.eventually.be.rejected; + // Make sure nothing changed. + const text1 = await fs.readFile(file1, 'utf8'); + expect(text1).to.equal(''); + const text2 = await fs.readFile(file2, 'utf8'); + expect(text2).to.equal(''); + const text3 = await fs.readFile(file3, 'utf8'); + expect(text3).to.equal(''); + }); + + test('fails if the file does not exist', async () => { + const source = await fix.resolve(DOES_NOT_EXIST); + const target = await fix.resolve('spam.py'); + + const promise = fileSystem.move(source, target); + + await expect(promise).to.eventually.be.rejected; + // Make sure nothing changed. + await assertDoesNotExist(target); + }); + + test('fails if the target directory does not exist', async () => { + const source = await fix.createFile('x/spam.py', ''); + const target = await fix.resolve('w/spam.py', false); + await assertDoesNotExist(path.dirname(target)); + + const promise = fileSystem.move(source, target); + + await expect(promise).to.eventually.be.rejected; + // Make sure nothing changed. + await assertExists(source); + await assertDoesNotExist(target); + }); + }); + + suite('readData', () => { + test('returns contents of a file', async () => { + const text = ''; + const expected = Buffer.from(text, 'utf8'); + const filename = await fix.createFile('x/y/z/spam.py', text); + + const content = await fileSystem.readData(filename); + + expect(content).to.deep.equal(expected); + }); + + test('throws an exception if file does not exist', async () => { + const promise = fileSystem.readData(DOES_NOT_EXIST); + + await expect(promise).to.eventually.be.rejected; + }); + }); + + suite('readText', () => { + test('returns contents of a file', async () => { + const expected = ''; + const filename = await fix.createFile('x/y/z/spam.py', expected); + + const content = await fileSystem.readText(filename); + + expect(content).to.be.equal(expected); + }); + + test('always UTF-8', async () => { + const expected = '... 😁 ...'; + const filename = await fix.createFile('x/y/z/spam.py', expected); + + const text = await fileSystem.readText(filename); + + expect(text).to.equal(expected); + }); + + test('returns garbage if encoding is UCS-2', async () => { + const filename = await fix.resolve('spam.py'); + // There are probably cases where this would fail too. + // However, the extension never has to deal with non-UTF8 + // cases, so it doesn't matter too much. + const original = '... 😁 ...'; + await fs.writeFile(filename, original, { encoding: 'ucs2' }); + + const text = await fileSystem.readText(filename); + + expect(text).to.equal('.\u0000.\u0000.\u0000 \u0000=�\u0001� \u0000.\u0000.\u0000.\u0000'); + }); + + test('throws an exception if file does not exist', async () => { + const promise = fileSystem.readText(DOES_NOT_EXIST); + + await expect(promise).to.eventually.be.rejected; + }); + }); + + suite('writeText', () => { + test('creates the file if missing', async () => { + const filename = await fix.resolve('x/y/z/spam.py'); + await assertDoesNotExist(filename); + const data = 'line1\nline2\n'; + + await fileSystem.writeText(filename, data); + + // prettier-ignore + const actual = await fs.readFile(filename) + .then(buffer => buffer.toString()); + expect(actual).to.equal(data); + }); + + test('always UTF-8', async () => { + const filename = await fix.resolve('x/y/z/spam.py'); + const data = '... 😁 ...'; + + await fileSystem.writeText(filename, data); + + // prettier-ignore + const actual = await fs.readFile(filename) + .then(buffer => buffer.toString()); + expect(actual).to.equal(data); + }); + + test('overwrites existing file', async () => { + const filename = await fix.createFile('x/y/z/spam.py', '...'); + const data = 'line1\nline2\n'; + + await fileSystem.writeText(filename, data); + + // prettier-ignore + const actual = await fs.readFile(filename) + .then(buffer => buffer.toString()); + expect(actual).to.equal(data); + }); + }); + + suite('appendText', () => { + test('existing file', async () => { + const orig = 'spamspamspam\n\n'; + const dataToAppend = `Some Data\n${new Date().toString()}\nAnd another line`; + const filename = await fix.createFile('spam.txt', orig); + const expected = `${orig}${dataToAppend}`; + + await fileSystem.appendText(filename, dataToAppend); + + const actual = await fs.readFile(filename, 'utf8'); + expect(actual).to.be.equal(expected); + }); + + test('existing empty file', async () => { + const filename = await fix.createFile('spam.txt'); + const dataToAppend = `Some Data\n${new Date().toString()}\nAnd another line`; + const expected = dataToAppend; + + await fileSystem.appendText(filename, dataToAppend); + + const actual = await fs.readFile(filename, 'utf8'); + expect(actual).to.be.equal(expected); + }); + + test('creates the file if it does not already exist', async () => { + await fileSystem.appendText(DOES_NOT_EXIST, 'spam'); + + const actual = await fs.readFile(DOES_NOT_EXIST, 'utf8'); + expect(actual).to.be.equal('spam'); + }); + + test('fails if not a file', async () => { + const dirname = await fix.createDirectory('spam'); + + const promise = fileSystem.appendText(dirname, 'spam'); + + await expect(promise).to.eventually.be.rejected; + }); + }); + + suite('copyFile', () => { + test('the source file gets copied (same directory)', async () => { + const data = ''; + const src = await fix.createFile('x/y/z/spam.py', data); + const dest = await fix.resolve('x/y/z/spam.py.bak'); + await assertDoesNotExist(dest); + + await fileSystem.copyFile(src, dest); + + // prettier-ignore + const actual = await fs.readFile(dest) + .then(buffer => buffer.toString()); + expect(actual).to.equal(data); + // prettier-ignore + const original = await fs.readFile(src) + .then(buffer => buffer.toString()); + expect(original).to.equal(data); + }); + + test('the source file gets copied (different directory)', async () => { + const data = ''; + const src = await fix.createFile('x/y/z/spam.py', data); + const dest = await fix.resolve('x/y/eggs.py'); + await assertDoesNotExist(dest); + + await fileSystem.copyFile(src, dest); + + // prettier-ignore + const actual = await fs.readFile(dest) + .then(buffer => buffer.toString()); + expect(actual).to.equal(data); + // prettier-ignore + const original = await fs.readFile(src) + .then(buffer => buffer.toString()); + expect(original).to.equal(data); + }); + + test('fails if the source does not exist', async () => { + const dest = await fix.resolve('x/spam.py'); + + const promise = fileSystem.copyFile(DOES_NOT_EXIST, dest); + + await expect(promise).to.eventually.be.rejected; + }); + + test('fails if the target parent directory does not exist', async () => { + const src = await fix.createFile('x/spam.py', '...'); + const dest = await fix.resolve('y/eggs.py', false); + await assertDoesNotExist(path.dirname(dest)); + + const promise = fileSystem.copyFile(src, dest); + + await expect(promise).to.eventually.be.rejected; + }); + }); + + suite('rmfile', () => { + test('deletes the file', async () => { + const filename = await fix.createFile('x/y/z/spam.py', '...'); + await assertExists(filename); + + await fileSystem.rmfile(filename); + + await assertDoesNotExist(filename); + }); + + test('fails if the file does not exist', async () => { + const promise = fileSystem.rmfile(DOES_NOT_EXIST); + + await expect(promise).to.eventually.be.rejected; + }); + }); + + suite('rmtree', () => { + test('deletes the directory if empty', async () => { + const dirname = await fix.createDirectory('x'); + await assertExists(dirname); + + await fileSystem.rmtree(dirname); + + await assertDoesNotExist(dirname); + }); + + test('fails if the directory is not empty', async () => { + const dirname = await fix.createDirectory('x'); + const filename = await fix.createFile('x/y/z/spam.py'); + await assertExists(filename); + + const promise = fileSystem.rmtree(dirname); + + await expect(promise).to.eventually.be.rejected; + }); + + test('fails if the directory does not exist', async () => { + const promise = fileSystem.rmtree(DOES_NOT_EXIST); + + await expect(promise).to.eventually.be.rejected; + }); + }); + + suite('mkdirp', () => { + test('creates the directory and all missing parents', async () => { + await fix.createDirectory('x'); + // x/y, x/y/z, and x/y/z/spam are all missing. + const dirname = await fix.resolve('x/y/z/spam', false); + await assertDoesNotExist(dirname); + + await fileSystem.mkdirp(dirname); + + await assertExists(dirname); + }); + + test('works if the directory already exists', async () => { + const dirname = await fix.createDirectory('spam'); + await assertExists(dirname); + + await fileSystem.mkdirp(dirname); + + await assertExists(dirname); + }); + }); + + suite('listdir', () => { + setup(function() { + if (WINDOWS) { + // tslint:disable-next-line:no-suspicious-comment + // TODO(GH-8995) These tests are failing on Windows, + // so we are // temporarily disabling it. + // tslint:disable-next-line:no-invalid-this + return this.skip(); + } + }); + if (SUPPORTS_SYMLINKS) { + test('mixed', async () => { + // Create the target directory and its contents. + const dirname = await fix.createDirectory('x/y/z'); + const file1 = await fix.createFile('x/y/z/__init__.py', ''); + const script = await fix.createFile('x/y/z/__main__.py', '