import sourceMapSupport from "source-map-support";
import * as fakes from "./_namespaces/fakes.js";
import * as FourSlashInterface from "./_namespaces/FourSlashInterface.js";
import * as Harness from "./_namespaces/Harness.js";
import * as ts from "./_namespaces/ts.js";
import * as Utils from "./_namespaces/Utils.js";
import * as vfs from "./_namespaces/vfs.js";
import * as vpath from "./_namespaces/vpath.js";
import { LoggerWithInMemoryLogs } from "./tsserverLogger.js";

import ArrayOrSingle = FourSlashInterface.ArrayOrSingle;
import { harnessTypingInstallerCacheLocation } from "./harnessLanguageService.js";
import { ensureWatchablePath } from "./watchUtils.js";

export const enum FourSlashTestType {
    Native,
    Server,
}

// Represents a parsed source file with metadata
interface FourSlashFile {
    // The contents of the file (with markers, etc stripped out)
    content: string;
    fileName: string;
    symlinks?: string[];
    version: number;
    // File-specific options (name/value pairs)
    fileOptions: Harness.TestCaseParser.CompilerSettings;
}

// Represents a set of parsed source files and options
interface FourSlashData {
    // Global options (name/value pairs)
    globalOptions: Harness.TestCaseParser.CompilerSettings;

    files: FourSlashFile[];

    symlinks: vfs.FileSet | undefined;

    // A mapping from marker names to name/position pairs
    markerPositions: Map<string, Marker>;

    markers: Marker[];

    /**
     * Inserted in source files by surrounding desired text
     * in a range with `[|` and `|]`. For example,
     *
     * [|text in range|]
     *
     * is a range with `text in range` "selected".
     */
    ranges: Range[];
    rangesByText?: ts.MultiMap<string, Range>;
}

export interface Marker {
    fileName: string;
    position: number;
    data?: {};
}

export interface Range extends ts.TextRange {
    fileName: string;
    marker?: Marker;
}

export type MarkerOrNameOrRange = string | Marker | Range;

interface LocationInformation {
    position: number;
    sourcePosition: number;
    sourceLine: number;
    sourceColumn: number;
}

interface RangeLocationInformation extends LocationInformation {
    marker?: Marker;
}

export interface TextSpan {
    start: number;
    end: number;
}

export interface VerbosityLevels {
    [markerName: string]: number | number[] | undefined;
}

// Name of testcase metadata including ts.CompilerOptions properties that will be used by globalOptions
// To add additional option, add property into the testOptMetadataNames, refer the property in either globalMetadataNames or fileMetadataNames
// Add cases into convertGlobalOptionsToCompilationsSettings function for the compiler to acknowledge such option from meta data
const enum MetadataOptionNames {
    baselineFile = "baselinefile",
    emitThisFile = "emitthisfile", // This flag is used for testing getEmitOutput feature. It allows test-cases to indicate what file to be output in multiple files project
    fileName = "filename",
    resolveReference = "resolvereference", // This flag is used to specify entry file for resolve file references. The flag is only allow once per test file
    symlink = "symlink",
}

// List of allowed metadata names
const fileMetadataNames = [MetadataOptionNames.fileName, MetadataOptionNames.emitThisFile, MetadataOptionNames.resolveReference, MetadataOptionNames.symlink];

function convertGlobalOptionsToCompilerOptions(globalOptions: Harness.TestCaseParser.CompilerSettings): ts.CompilerOptions {
    const settings: ts.CompilerOptions = { ...ts.getDefaultCompilerOptions(), jsx: undefined, newLine: ts.NewLineKind.CarriageReturnLineFeed };
    Harness.Compiler.setCompilerOptionsFromHarnessSetting(globalOptions, settings);
    return settings;
}

function isMarker(x: Marker | Range): x is Marker {
    return (x as Marker).position !== undefined;
}

function convertDocumentSpanToString<T extends ts.DocumentSpan>(span: T, prefix?: string, ignoredProperties?: readonly string[]) {
    let text = prefix || "";
    for (const p in span) {
        if (p === "textSpan" || p === "fileName" || p === "contextSpan") continue;
        if (ts.contains(ignoredProperties, p)) continue;
        if (ts.hasProperty(span, p) && !!span[p]) { // Serialize truthy properties
            const propText = `${p}: ${JSON.stringify(span[p])}`;
            if (text) text += `, ${propText}`;
            else text = propText;
        }
    }
    return text;
}

function readableJsoncBaseline(content: string) {
    return content.split(/\r?\n/).map(l => "// " + l).join("\n");
}

function indentJsonBaseline(content: string) {
    return content.split(/\r?\n/).map(l => l ? "  " + l : "").join("\n");
}

interface MarkerAndNameForBaseline {
    markerOrRange: MarkerOrNameOrRange;
    markerName: string;
}
interface BaselineDocumentSpansWithFileContentsOptions<T extends ts.DocumentSpan> {
    markerInfo: MarkerAndNameForBaseline | undefined;
    documentSpanId?: (span: T) => string;
    skipDocumentSpanDetails?: boolean;
    skipDocumentContainingOnlyMarker?: boolean;
    endMarker?: string;
    startMarkerPrefix?: (span: T) => string | undefined;
    endMarkerSuffix?: (span: T) => string | undefined;
    ignoredDocumentSpanProperties?: readonly string[];
    additionalSpan?: ts.DocumentSpan;
}

export class TestCancellationToken implements ts.HostCancellationToken {
    // 0 - cancelled
    // >0 - not cancelled
    // <0 - not cancelled and value denotes number of isCancellationRequested after which token become cancelled
    private static readonly notCanceled = -1;
    private numberOfCallsBeforeCancellation = TestCancellationToken.notCanceled;

    public isCancellationRequested(): boolean {
        if (this.numberOfCallsBeforeCancellation < 0) {
            return false;
        }

        if (this.numberOfCallsBeforeCancellation > 0) {
            this.numberOfCallsBeforeCancellation--;
            return false;
        }

        return true;
    }

    public setCancelled(numberOfCalls = 0): void {
        ts.Debug.assert(numberOfCalls >= 0);
        this.numberOfCallsBeforeCancellation = numberOfCalls;
    }

    public resetCancelled(): void {
        this.numberOfCallsBeforeCancellation = TestCancellationToken.notCanceled;
    }
}

export function verifyOperationIsCancelled(f: () => void): void {
    try {
        f();
    }
    catch (e) {
        if (e instanceof ts.OperationCanceledException) {
            return;
        }
    }

    throw new Error("Operation should be cancelled");
}

export function ignoreInterpolations(diagnostic: string | ts.DiagnosticMessage): FourSlashInterface.DiagnosticIgnoredInterpolations {
    return { template: typeof diagnostic === "string" ? diagnostic : diagnostic.message };
}

// This function creates IScriptSnapshot object for testing getPreProcessedFileInfo
// Return object may lack some functionalities for other purposes.
function createScriptSnapShot(sourceText: string): ts.IScriptSnapshot {
    return ts.ScriptSnapshot.fromString(sourceText);
}

const enum CallHierarchyItemDirection {
    Root,
    Incoming,
    Outgoing,
}

interface RealizedDiagnostic {
    message: string;
    start: number;
    length: number;
    category: string;
    code: number;
    reportsUnnecessary?: {};
    reportsDeprecated?: {};
}

function realizeDiagnostics(diagnostics: readonly ts.Diagnostic[], newLine: string): RealizedDiagnostic[] {
    return diagnostics.map(d => realizeDiagnostic(d, newLine));
}

function realizeDiagnostic(diagnostic: ts.Diagnostic, newLine: string): RealizedDiagnostic {
    return {
        message: ts.flattenDiagnosticMessageText(diagnostic.messageText, newLine),
        start: diagnostic.start!, // TODO: GH#18217
        length: diagnostic.length!, // TODO: GH#18217
        category: ts.diagnosticCategoryName(diagnostic),
        code: diagnostic.code,
        reportsUnnecessary: diagnostic.reportsUnnecessary,
        reportsDeprecated: diagnostic.reportsDeprecated,
    };
}

interface BaselineTest {
    command: string;
    actual: string;
    ext: string | undefined;
}

export class TestState {
    // Language service instance
    private languageServiceAdapterHost: Harness.LanguageService.LanguageServiceAdapterHost;
    private languageService: ts.LanguageService;
    private cancellationToken: TestCancellationToken;
    private assertTextConsistent: ((fileName: string) => void) | undefined;

    // The current caret position in the active file
    public currentCaretPosition = 0;
    // The position of the end of the current selection, or -1 if nothing is selected
    public selectionEnd = -1;

    public lastKnownMarker: string | undefined;

    // The file that's currently 'opened'
    public activeFile!: FourSlashFile;

    // Whether or not we should format on keystrokes
    public enableFormatting = true;

    public formatCodeSettings: ts.FormatCodeSettings;

    private inputFiles = new Map<string, string>(); // Map between inputFile's fileName and its content for easily looking up when resolving references

    private logger: LoggerWithInMemoryLogs | undefined;

    private static getDisplayPartsJson(displayParts: ts.SymbolDisplayPart[] | undefined) {
        let result = "";
        ts.forEach(displayParts, part => {
            if (result) {
                result += ",\n    ";
            }
            else {
                result = "[\n    ";
            }
            result += JSON.stringify(part);
        });
        if (result) {
            result += "\n]";
        }

        return result;
    }

    // Add input file which has matched file name with the given reference-file path.
    // This is necessary when resolveReference flag is specified
    private addMatchedInputFile(referenceFilePath: string, extensions: readonly string[] | undefined) {
        const inputFiles = this.inputFiles;
        const languageServiceAdapterHost = this.languageServiceAdapterHost;
        const didAdd = tryAdd(referenceFilePath);
        if (extensions && !didAdd) {
            ts.forEach(extensions, ext => tryAdd(referenceFilePath + ext));
        }

        function tryAdd(path: string) {
            const inputFile = inputFiles.get(path);
            if (inputFile && !Harness.isDefaultLibraryFile(path)) {
                languageServiceAdapterHost.addScript(path, inputFile, /*isRootFile*/ true);
                return true;
            }
        }
    }

    private getLanguageServiceAdapter(testType: FourSlashTestType, cancellationToken: TestCancellationToken, compilationOptions: ts.CompilerOptions): Harness.LanguageService.LanguageServiceAdapter {
        switch (testType) {
            case FourSlashTestType.Native:
                return new Harness.LanguageService.NativeLanguageServiceAdapter(cancellationToken, compilationOptions);
            case FourSlashTestType.Server:
                return new Harness.LanguageService.ServerLanguageServiceAdapter(cancellationToken, compilationOptions);
            default:
                throw new Error("Unknown FourSlash test type: ");
        }
    }

    constructor(private originalInputFileName: string, private basePath: string, private testType: FourSlashTestType, public testData: FourSlashData) {
        // Create a new Services Adapter
        this.cancellationToken = new TestCancellationToken();
        let compilationOptions = convertGlobalOptionsToCompilerOptions(this.testData.globalOptions);
        compilationOptions.skipDefaultLibCheck = true;

        // Initialize the language service with all the scripts
        let startResolveFileRef: FourSlashFile | undefined;

        let configFileName: string | undefined;
        if (testData.symlinks && this.testType === FourSlashTestType.Server) {
            for (const symlink of ts.getOwnKeys(testData.symlinks)) {
                ensureWatchablePath(ts.getDirectoryPath(symlink), `Directory of input link: ${symlink}`);
                const target = (testData.symlinks[symlink] as vfs.Symlink).symlink;
                ensureWatchablePath(ts.getDirectoryPath(target), `Directory of target link: ${target} for symlink ${symlink}`);
            }
        }
        for (const file of testData.files) {
            if (this.testType === FourSlashTestType.Server) {
                ensureWatchablePath(ts.getDirectoryPath(file.fileName), `Directory of input file: ${file.fileName}`);
            }
            // Create map between fileName and its content for easily looking up when resolveReference flag is specified
            this.inputFiles.set(file.fileName, file.content);
            if (isConfig(file)) {
                const configJson = ts.parseConfigFileTextToJson(file.fileName, file.content);
                if (configJson.config === undefined) {
                    throw new Error(`Failed to parse test ${file.fileName}: ${configJson.error!.messageText}`);
                }

                // Extend our existing compiler options so that we can also support tsconfig only options
                if (configJson.config.compilerOptions) {
                    const baseDirectory = ts.normalizePath(ts.getDirectoryPath(file.fileName));
                    const tsConfig = ts.convertCompilerOptionsFromJson(configJson.config.compilerOptions, baseDirectory, file.fileName);

                    if (!tsConfig.errors || !tsConfig.errors.length) {
                        compilationOptions = ts.extend(tsConfig.options, compilationOptions);
                    }
                }
                configFileName = file.fileName;
            }

            if (!startResolveFileRef && file.fileOptions[MetadataOptionNames.resolveReference] === "true") {
                startResolveFileRef = file;
            }
            else if (startResolveFileRef) {
                // If entry point for resolving file references is already specified, report duplication error
                throw new Error("There exists a Fourslash file which has resolveReference flag specified; remove duplicated resolveReference flag");
            }
        }

        let configParseResult: ts.ParsedCommandLine | undefined;
        if (configFileName) {
            const baseDir = ts.normalizePath(ts.getDirectoryPath(configFileName));
            const files: vfs.FileSet = { [baseDir]: {} };
            this.inputFiles.forEach((data, path) => {
                const scriptInfo = new Harness.LanguageService.ScriptInfo(path, undefined!, /*isRootFile*/ false); // TODO: GH#18217
                files[path] = new vfs.File(data, { meta: { scriptInfo } });
            });
            const fs = new vfs.FileSystem(/*ignoreCase*/ true, { cwd: baseDir, files });
            const host = new fakes.ParseConfigHost(fs);
            const jsonSourceFile = ts.parseJsonText(configFileName, this.inputFiles.get(configFileName)!);
            configParseResult = ts.parseJsonSourceFileConfigFileContent(jsonSourceFile, host, baseDir, compilationOptions, configFileName);
            compilationOptions = configParseResult.options;
        }

        if (compilationOptions.typeRoots) {
            compilationOptions.typeRoots = compilationOptions.typeRoots.map(p => ts.getNormalizedAbsolutePath(p, this.basePath));
        }

        const languageServiceAdapter = this.getLanguageServiceAdapter(testType, this.cancellationToken, compilationOptions);
        this.logger = languageServiceAdapter.getLogger();
        this.languageServiceAdapterHost = languageServiceAdapter.getHost();
        this.languageService = memoWrap(languageServiceAdapter.getLanguageService(), this); // Wrap the LS to cache some expensive operations certain tests call repeatedly
        if (this.testType === FourSlashTestType.Server) {
            this.assertTextConsistent = fileName => (languageServiceAdapter as Harness.LanguageService.ServerLanguageServiceAdapter).assertTextConsistent(fileName);
        }

        if (startResolveFileRef) {
            // Add the entry-point file itself into the languageServiceShimHost
            this.languageServiceAdapterHost.addScript(startResolveFileRef.fileName, startResolveFileRef.content, /*isRootFile*/ true);

            const resolvedResult = languageServiceAdapter.getPreProcessedFileInfo(startResolveFileRef.fileName, startResolveFileRef.content);
            const referencedFiles: ts.FileReference[] = resolvedResult.referencedFiles;
            const importedFiles: ts.FileReference[] = resolvedResult.importedFiles;

            // Add triple reference files into language-service host
            ts.forEach(referencedFiles, referenceFile => {
                // Fourslash insert tests/cases/fourslash into inputFile.unitName so we will properly append the same base directory to refFile path
                const referenceFilePath = this.basePath + "/" + referenceFile.fileName;
                this.addMatchedInputFile(referenceFilePath, /*extensions*/ undefined);
            });

            const exts = ts.flatten(ts.getSupportedExtensions(compilationOptions));
            // Add import files into language-service host
            ts.forEach(importedFiles, importedFile => {
                // Fourslash insert tests/cases/fourslash into inputFile.unitName and import statement doesn't require ".ts"
                // so convert them before making appropriate comparison
                const importedFilePath = this.basePath + "/" + importedFile.fileName;
                this.addMatchedInputFile(importedFilePath, exts);
            });
        }
        else {
            // resolveReference file-option is not specified then do not resolve any files and include all inputFiles
            this.inputFiles.forEach((file, fileName) => {
                if (!Harness.isDefaultLibraryFile(fileName)) {
                    // all files if config file not specified, otherwise root files from the config and typings cache files are root files
                    const isRootFile = !configParseResult ||
                        ts.contains(configParseResult.fileNames, fileName) ||
                        (ts.isDeclarationFileName(fileName) && ts.containsPath(harnessTypingInstallerCacheLocation, fileName));
                    this.languageServiceAdapterHost.addScript(fileName, file, isRootFile);
                }
            });
        }

        for (const file of testData.files) {
            ts.forEach(file.symlinks, link => {
                this.languageServiceAdapterHost.vfs.mkdirpSync(vpath.dirname(link));
                this.languageServiceAdapterHost.vfs.symlinkSync(file.fileName, link);
            });
        }

        if (testData.symlinks) {
            this.languageServiceAdapterHost.vfs.apply(testData.symlinks);
        }

        this.formatCodeSettings = ts.testFormatSettings;

        if (this.logger?.loggingEnabled()) {
            const patch = this.languageServiceAdapterHost.vfs.diff();
            this.logger.log(vfs.formatPatch(patch) || "");
        }

        // Open the first file by default
        this.openFile(0);

        function memoWrap(ls: ts.LanguageService, target: TestState): ts.LanguageService {
            const cacheableMembers: (keyof typeof ls)[] = [
                "getCompletionEntryDetails",
                "getCompletionEntrySymbol",
                "getQuickInfoAtPosition",
                "getReferencesAtPosition",
                "getDocumentHighlights",
            ];
            const proxy = {} as ts.LanguageService;
            const keys = ts.getAllKeys(ls);
            for (const k of keys) {
                const key = k as keyof typeof ls;
                if (!cacheableMembers.includes(key)) {
                    proxy[key] = (...args: any[]) => (ls[key] as (...args: any[]) => any)(...args);
                    continue;
                }
                const memo = Utils.memoize(
                    (_version: number, _active: string, _caret: number, _selectEnd: number, _marker: string | undefined, ...args: any[]) => (ls[key] as (...args: any[]) => any)(...args),
                    (...args) => args.map(a => a && typeof a === "object" ? JSON.stringify(a) : a).join("|,|"),
                );
                proxy[key] = (...args: any[]) =>
                    memo(
                        target.languageServiceAdapterHost.getScriptInfo(target.activeFile.fileName)!.version,
                        target.activeFile.fileName,
                        target.currentCaretPosition,
                        target.selectionEnd,
                        target.lastKnownMarker,
                        ...args,
                    );
            }
            return proxy;
        }
    }

    private baselineFromTest: BaselineTest[] | undefined;

    private baseline(command: string, actual: string, ext?: string) {
        if (!this.baselineFromTest) this.baselineFromTest = [{ command, actual, ext }];
        else this.baselineFromTest.push({ command, actual, ext });
    }

    baselineTest(): void {
        if (this.baselineFromTest) {
            Harness.Baseline.runBaseline(
                this.getBaselineFileNameForContainingTestFile(this.baselineFromTest[0].ext),
                this.baselineFromTest.map(({ command, actual }) => `// === ${command} ===\n${actual}`).join("\n\n\n\n"),
            );
        }
    }

    baselineTsserverLog(): void {
        if (this.logger) {
            Harness.Baseline.runBaseline(
                `tsserver/fourslashServer/${ts.getBaseFileName(this.originalInputFileName).replace(".ts", ".js")}`,
                this.logger.logs.join("\n"),
            );
        }
    }

    private getFileContent(fileName: string): string {
        return ts.Debug.checkDefined(this.tryGetFileContent(fileName));
    }
    private tryGetFileContent(fileName: string): string | undefined {
        const script = this.languageServiceAdapterHost.getScriptInfo(fileName);
        if (script) return script.content;
        try {
            return this.languageServiceAdapterHost.vfs.readFileSync(fileName, "utf8");
        }
        catch {
            return undefined;
        }
    }

    // Entry points from fourslash.ts
    public goToMarker(name: string | Marker = ""): void {
        const marker = ts.isString(name) ? this.getMarkerByName(name) : name;
        if (this.activeFile.fileName !== marker.fileName) {
            this.openFile(marker.fileName);
        }

        const content = this.getFileContent(marker.fileName);
        if (marker.position === -1 || marker.position > content.length) {
            throw new Error(`Marker "${name}" has been invalidated by unrecoverable edits to the file.`);
        }
        const mName = ts.isString(name) ? name : this.markerName(marker);
        this.lastKnownMarker = mName;
        this.goToPosition(marker.position);
    }

    private goToMarkerOrNameOrRange(markerOrRange: MarkerOrNameOrRange) {
        return ts.isString(markerOrRange) || isMarker(markerOrRange) ?
            this.goToMarker(markerOrRange) :
            this.goToRangeStart(markerOrRange);
    }

    public goToEachMarker(markers: readonly Marker[], action: (marker: Marker, index: number) => void): void {
        assert(markers.length);
        for (let i = 0; i < markers.length; i++) {
            this.goToMarker(markers[i]);
            action(markers[i], i);
        }
    }

    public goToEachRange(action: (range: Range) => void): void {
        const ranges = this.getRanges();
        assert(ranges.length);
        for (const range of ranges) {
            this.selectRange(range);
            action(range);
        }
    }

    public markerName(m: Marker): string {
        return ts.forEachEntry(this.testData.markerPositions, (marker, name) => {
            if (marker === m) {
                return name;
            }
        })!;
    }

    public goToPosition(positionOrLineAndCharacter: number | ts.LineAndCharacter): void {
        const pos = typeof positionOrLineAndCharacter === "number"
            ? positionOrLineAndCharacter
            : this.languageServiceAdapterHost.lineAndCharacterToPosition(this.activeFile.fileName, positionOrLineAndCharacter);
        this.currentCaretPosition = pos;
        this.selectionEnd = -1;
    }

    public select(startMarker: string, endMarker: string): void {
        const start = this.getMarkerByName(startMarker), end = this.getMarkerByName(endMarker);
        ts.Debug.assert(start.fileName === end.fileName);
        if (this.activeFile.fileName !== start.fileName) {
            this.openFile(start.fileName);
        }
        this.goToPosition(start.position);
        this.selectionEnd = end.position;
    }

    public selectAllInFile(fileName: string): void {
        this.openFile(fileName);
        this.goToPosition(0);
        this.selectionEnd = this.activeFile.content.length;
    }

    public selectRange(range: Range): void {
        this.goToRangeStart(range);
        this.selectionEnd = range.end;
    }

    public selectLine(index: number): void {
        const lineStart = this.languageServiceAdapterHost.lineAndCharacterToPosition(this.activeFile.fileName, { line: index, character: 0 });
        const lineEnd = lineStart + this.getLineContent(index).length;
        this.selectRange({ fileName: this.activeFile.fileName, pos: lineStart, end: lineEnd });
    }

    public moveCaretRight(count = 1): void {
        this.currentCaretPosition += count;
        this.currentCaretPosition = Math.min(this.currentCaretPosition, this.getFileContent(this.activeFile.fileName).length);
        this.selectionEnd = -1;
    }

    // Opens a file given its 0-based index or fileName
    public openFile(indexOrName: number | string, content?: string, scriptKindName?: string): void {
        const fileToOpen: FourSlashFile = this.findFile(indexOrName);
        fileToOpen.fileName = ts.normalizeSlashes(fileToOpen.fileName);
        this.activeFile = fileToOpen;
        // Let the host know that this file is now open
        this.languageServiceAdapterHost.openFile(fileToOpen.fileName, content, scriptKindName);
    }

    public verifyErrorExistsBetweenMarkers(startMarkerName: string, endMarkerName: string, shouldExist: boolean): void {
        const startMarker = this.getMarkerByName(startMarkerName);
        const endMarker = this.getMarkerByName(endMarkerName);
        const predicate = (errorMinChar: number, errorLimChar: number, startPos: number, endPos: number | undefined) => ((errorMinChar === startPos) && (errorLimChar === endPos)) ? true : false;

        const exists = this.anyErrorInRange(predicate, startMarker, endMarker);

        if (exists !== shouldExist) {
            this.printErrorLog(shouldExist, this.getAllDiagnostics());
            throw new Error(`${shouldExist ? "Expected" : "Did not expect"} failure between markers: '${startMarkerName}', '${endMarkerName}'`);
        }
    }

    public verifyOrganizeImports(newContent: string, mode?: ts.OrganizeImportsMode, preferences?: ts.UserPreferences): void {
        const changes = this.languageService.organizeImports({ fileName: this.activeFile.fileName, type: "file", mode }, this.formatCodeSettings, preferences);
        this.applyChanges(changes);
        this.verifyFileContent(this.activeFile.fileName, newContent);
    }

    private raiseError(message: string): never {
        throw new Error(this.messageAtLastKnownMarker(message));
    }

    private messageAtLastKnownMarker(message: string) {
        const locationDescription = this.lastKnownMarker !== undefined ? this.lastKnownMarker : this.getLineColStringAtPosition(this.currentCaretPosition);
        return `At marker '${locationDescription}': ${message}`;
    }

    private assertionMessageAtLastKnownMarker(msg: string) {
        return "\nMarker: " + this.lastKnownMarker + "\nChecking: " + msg + "\n\n";
    }

    private getDiagnostics(fileName: string, includeSuggestions = false): ts.Diagnostic[] {
        return [
            ...this.languageService.getSyntacticDiagnostics(fileName),
            ...this.languageService.getSemanticDiagnostics(fileName),
            ...(includeSuggestions ? this.languageService.getSuggestionDiagnostics(fileName) : ts.emptyArray),
        ];
    }

    private getAllDiagnostics(): readonly ts.Diagnostic[] {
        return ts.flatMap(this.languageServiceAdapterHost.getFilenames(), fileName => {
            if (!ts.isAnySupportedFileExtension(fileName)) {
                return [];
            }

            const baseName = ts.getBaseFileName(fileName);
            if (baseName === "package.json" || baseName === "tsconfig.json" || baseName === "jsconfig.json") {
                return [];
            }
            return this.getDiagnostics(fileName);
        });
    }

    public verifyErrorExistsAfterMarker(markerName: string, shouldExist: boolean, after: boolean): void {
        const marker: Marker = this.getMarkerByName(markerName);
        let predicate: (errorMinChar: number, errorLimChar: number, startPos: number, endPos: number | undefined) => boolean;

        if (after) {
            predicate = (errorMinChar: number, errorLimChar: number, startPos: number) => ((errorMinChar >= startPos) && (errorLimChar >= startPos)) ? true : false;
        }
        else {
            predicate = (errorMinChar: number, errorLimChar: number, startPos: number) => ((errorMinChar <= startPos) && (errorLimChar <= startPos)) ? true : false;
        }

        const exists = this.anyErrorInRange(predicate, marker);
        const diagnostics = this.getAllDiagnostics();

        if (exists !== shouldExist) {
            this.printErrorLog(shouldExist, diagnostics);
            throw new Error(`${shouldExist ? "Expected" : "Did not expect"} failure at marker '${markerName}'`);
        }
    }

    private anyErrorInRange(predicate: (errorMinChar: number, errorLimChar: number, startPos: number, endPos: number | undefined) => boolean, startMarker: Marker, endMarker?: Marker): boolean {
        return this.getDiagnostics(startMarker.fileName).some(({ start, length }) => predicate(start!, start! + length!, startMarker.position, endMarker === undefined ? undefined : endMarker.position)); // TODO: GH#18217
    }

    private printErrorLog(expectErrors: boolean, errors: readonly ts.Diagnostic[]): void {
        if (expectErrors) {
            Harness.IO.log("Expected error not found.  Error list is:");
        }
        else {
            Harness.IO.log("Unexpected error(s) found.  Error list is:");
        }

        for (const { start, length, messageText, file } of errors) {
            Harness.IO.log(
                "  " + this.formatRange(file, start!, length!) + // TODO: GH#18217
                    ", message: " + ts.flattenDiagnosticMessageText(messageText, Harness.IO.newLine()) + "\n",
            );
        }
    }

    private formatRange(file: ts.SourceFile | undefined, start: number, length: number) {
        if (file) {
            return `from: ${this.formatLineAndCharacterOfPosition(file, start)}, to: ${this.formatLineAndCharacterOfPosition(file, start + length)}`;
        }
        return "global";
    }

    private formatLineAndCharacterOfPosition(file: ts.SourceFile, pos: number) {
        if (file) {
            const { line, character } = ts.getLineAndCharacterOfPosition(file, pos);
            return `${line}:${character}`;
        }
        return "global";
    }

    private formatPosition(file: ts.SourceFile, pos: number) {
        if (file) {
            return file.fileName + "@" + pos;
        }
        return "global";
    }

    public verifyNoErrors(): void {
        ts.forEachKey(this.inputFiles, fileName => {
            if (
                !ts.isAnySupportedFileExtension(fileName)
                || Harness.getConfigNameFromFileName(fileName)
                // Can't get a Program in Server tests
                || this.testType !== FourSlashTestType.Server && !ts.getAllowJSCompilerOption(this.getProgram().getCompilerOptions()) && !ts.resolutionExtensionIsTSOrJson(ts.extensionFromPath(fileName))
                || ts.getBaseFileName(fileName) === "package.json"
            ) return;
            const errors = this.getDiagnostics(fileName).filter(e => e.category !== ts.DiagnosticCategory.Suggestion);
            if (errors.length) {
                this.printErrorLog(/*expectErrors*/ false, errors);
                const error = errors[0];
                const message = typeof error.messageText === "string" ? error.messageText : error.messageText.messageText;
                this.raiseError(`Found an error: ${this.formatPosition(error.file!, error.start!)}: ${message}`);
            }
        });
    }

    public verifyErrorExistsAtRange(range: Range, code: number, expectedMessage?: string): void {
        const span = ts.createTextSpanFromRange(range);
        const hasMatchingError = ts.some(
            this.getDiagnostics(range.fileName),
            ({ code, messageText, start, length }) =>
                code === code &&
                (!expectedMessage || expectedMessage === messageText) &&
                ts.isNumber(start) && ts.isNumber(length) &&
                ts.textSpansEqual(span, { start, length }),
        );

        if (!hasMatchingError) {
            this.raiseError(`No error with code ${code} found at provided range.`);
        }
    }

    public verifyNumberOfErrorsInCurrentFile(expected: number): void {
        const errors = this.getDiagnostics(this.activeFile.fileName);
        const actual = errors.length;

        if (actual !== expected) {
            this.printErrorLog(/*expectErrors*/ false, errors);
            const errorMsg = "Actual number of errors (" + actual + ") does not match expected number (" + expected + ")";
            Harness.IO.log(errorMsg);
            this.raiseError(errorMsg);
        }
    }

    public verifyEval(expr: string, value: any): void {
        const emit = this.languageService.getEmitOutput(this.activeFile.fileName);
        if (emit.outputFiles.length !== 1) {
            throw new Error("Expected exactly one output from emit of " + this.activeFile.fileName);
        }

        const evaluation = new Function(`${emit.outputFiles[0].text};\r\nreturn (${expr});`)(); // eslint-disable-line no-new-func
        if (evaluation !== value) {
            this.raiseError(`Expected evaluation of expression "${expr}" to equal "${value}", but got "${evaluation}"`);
        }
    }

    private getGoToDefinition(): readonly ts.DefinitionInfo[] {
        return this.languageService.getDefinitionAtPosition(this.activeFile.fileName, this.currentCaretPosition)!;
    }

    private getGoToDefinitionAndBoundSpan(): ts.DefinitionInfoAndBoundSpan {
        return this.languageService.getDefinitionAndBoundSpan(this.activeFile.fileName, this.currentCaretPosition)!;
    }

    private renderMarkers(markers: { text: string; fileName: string; position: number; }[], useTerminalBoldSequence = true) {
        const filesToDisplay = ts.deduplicate(markers.map(m => m.fileName), ts.equateValues);
        return filesToDisplay.map(fileName => {
            const markersToRender = markers.filter(m => m.fileName === fileName).sort((a, b) => b.position - a.position);
            let fileContent = this.tryGetFileContent(fileName) || "";
            for (const marker of markersToRender) {
                fileContent = fileContent.slice(0, marker.position) + bold(`/*${marker.text}*/`) + fileContent.slice(marker.position);
            }
            return `// @Filename: ${fileName}\n${fileContent}`;
        }).join("\n\n");

        function bold(text: string) {
            return useTerminalBoldSequence ? `\x1b[1;4m${text}\x1b[0;31m` : text;
        }
    }

    private baselineGoToDefs(
        markerName: string,
        markerOrRange: MarkerOrNameOrRange,
        getDefs: () => readonly ts.DefinitionInfo[] | readonly ts.ImplementationLocation[] | ts.DefinitionInfoAndBoundSpan | undefined,
    ) {
        this.goToMarkerOrNameOrRange(markerOrRange);
        const defs = getDefs();
        const defIdMap = new Map<ts.DefinitionInfo | ts.ImplementationLocation, number>();
        const definitions = defs ? ts.isArray(defs) ? defs : defs.definitions : undefined;
        if (definitions?.length! > 1) {
            definitions!.forEach((def, index) => defIdMap.set(def, index));
        }
        let baseline = this.getBaselineForDocumentSpansWithFileContents<ts.DefinitionInfo | ts.ImplementationLocation>(
            definitions,
            {
                markerInfo: { markerOrRange, markerName },
                documentSpanId: defIdMap.size ? def => `defId: ${defIdMap.get(def)}` : undefined,
                skipDocumentSpanDetails: true,
                additionalSpan: defs && !ts.isArray(defs) ? { fileName: this.activeFile.fileName, textSpan: defs.textSpan } : undefined,
            },
        );
        if (definitions?.length) {
            baseline += "\n\n";
            baseline += indentJsonBaseline(
                "// === Details ===\n" +
                    JSON.stringify(
                        definitions.map(def => ({
                            defId: defIdMap.get(def),
                            ...def,
                            fileName: undefined,
                            textSpan: undefined,
                            contextSpan: undefined,
                        })),
                        undefined,
                        " ",
                    ),
            );
        }
        return baseline;
    }

    public baselineGoToDefinition(
        markerOrRange: MarkerOrNameOrRange[] | undefined,
        rangeText: string[] | undefined,
    ): void {
        this.baselineEachMarkerOrRange("goToDefinition", markerOrRange, rangeText, markerOrRange =>
            this.baselineGoToDefs(
                "/*GOTO DEF*/",
                markerOrRange,
                () => this.getGoToDefinitionAndBoundSpan(),
            ));
    }

    public baselineGetDefinitionAtPosition(
        markerOrRange: MarkerOrNameOrRange[] | undefined,
        rangeText: string[] | undefined,
    ): void {
        this.baselineEachMarkerOrRange("getDefinitionAtPosition", markerOrRange, rangeText, markerOrRange =>
            this.baselineGoToDefs(
                "/*GOTO DEF POS*/",
                markerOrRange,
                () => this.getGoToDefinition(),
            ));
    }

    public baselineGoToSourceDefinition(
        markerOrRange: MarkerOrNameOrRange[] | undefined,
        rangeText: string[] | undefined,
    ): void {
        if (this.testType !== FourSlashTestType.Server) {
            this.raiseError("goToSourceDefinition may only be used in fourslash/server tests.");
        }
        this.baselineEachMarkerOrRange("goToSourceDefinition", markerOrRange, rangeText, markerOrRange =>
            this.baselineGoToDefs(
                "/*GOTO SOURCE DEF*/",
                markerOrRange,
                () =>
                    (this.languageService as ts.server.SessionClient)
                        .getSourceDefinitionAndBoundSpan(this.activeFile.fileName, this.currentCaretPosition),
            ));
    }

    public baselineGoToType(
        markerOrRange: MarkerOrNameOrRange[] | undefined,
        rangeText: string[] | undefined,
    ): void {
        this.baselineEachMarkerOrRange("goToType", markerOrRange, rangeText, markerOrRange =>
            this.baselineGoToDefs(
                "/*GOTO TYPE*/",
                markerOrRange,
                () => this.languageService.getTypeDefinitionAtPosition(this.activeFile.fileName, this.currentCaretPosition),
            ));
    }

    public baselineGoToImplementation(
        markerOrRange: MarkerOrNameOrRange[] | undefined,
        rangeText: string[] | undefined,
    ): void {
        this.baselineEachMarkerOrRange("goToImplementation", markerOrRange, rangeText, markerOrRange =>
            this.baselineGoToDefs(
                "/*GOTO IMPL*/",
                markerOrRange,
                () => this.languageService.getImplementationAtPosition(this.activeFile.fileName, this.currentCaretPosition),
            ));
    }

    public verifyGetEmitOutputForCurrentFile(expected: string): void {
        const emit = this.languageService.getEmitOutput(this.activeFile.fileName);
        if (emit.outputFiles.length !== 1) {
            throw new Error("Expected exactly one output from emit of " + this.activeFile.fileName);
        }
        const actual = emit.outputFiles[0].text;
        if (actual !== expected) {
            this.raiseError(`Expected emit output to be "${expected}", but got "${actual}"`);
        }
    }

    public verifyGetEmitOutputContentsForCurrentFile(expected: ts.OutputFile[]): void {
        const emit = this.languageService.getEmitOutput(this.activeFile.fileName);
        assert.equal(emit.outputFiles.length, expected.length, "Number of emit output files");
        ts.zipWith(emit.outputFiles, expected, (outputFile, expected) => {
            assert.equal(outputFile.name, expected.name, "FileName");
            assert.equal(outputFile.text, expected.text, "Content");
        });
    }

    public baselineInlayHints(span: ts.TextSpan = { start: 0, length: this.activeFile.content.length }, preferences?: ts.UserPreferences): void {
        interface HasPosition {
            position: number;
        }
        const sortHints = (a: HasPosition, b: HasPosition) => {
            return a.position - b.position;
        };

        const fileName = this.activeFile.fileName;
        const hints = this.languageService.provideInlayHints(fileName, span, preferences);
        const annotations = ts.map(hints.slice().sort(sortHints), hint => {
            if (hint.displayParts) {
                hint.displayParts = ts.map(hint.displayParts, part => {
                    if (part.file && /lib.*\.d\.ts$/.test(part.file)) {
                        part.span!.start = -1;
                    }
                    return part;
                });
            }

            const span = { start: hint.position, length: hint.text.length };
            const { character, line } = this.languageServiceAdapterHost.positionToLineAndCharacter(fileName, span.start);
            const underline = " ".repeat(character) + "^";
            let annotation = this.getFileContent(fileName).split(/\r?\n/)[line];
            annotation += "\n" + underline + "\n" + JSON.stringify(hint, undefined, "  ");
            return annotation;
        });

        if (annotations.length === 0) {
            annotations.push("=== No inlay hints ===");
        }

        this.baseline("Inlay Hints", annotations.join("\n\n"));
    }

    public verifyCompletions(options: FourSlashInterface.VerifyCompletionsOptions): FourSlashInterface.CompletionsResult | undefined {
        if (options.marker === undefined) {
            return this.verifyCompletionsWorker(options);
        }
        else {
            if (ts.isArray(options.marker)) {
                for (const marker of options.marker) {
                    this.goToMarker(marker);
                    this.verifyCompletionsWorker({ ...options, marker });
                }
                return {
                    andApplyCodeAction: () => {
                        this.raiseError(`Cannot apply code action when multiple markers are specified.`);
                    },
                };
            }
            this.goToMarker(options.marker);
            return this.verifyCompletionsWorker({ ...options, marker: options.marker });
        }
    }

    private verifyCompletionsWorker(options: FourSlashInterface.VerifyCompletionsOptions) {
        const preferences = options.preferences;
        const actualCompletions = this.getCompletionListAtCaret({ ...preferences, triggerCharacter: options.triggerCharacter })!;
        if (!actualCompletions) {
            if (ts.hasProperty(options, "exact") && (options.exact === undefined || ts.isArray(options.exact) && !options.exact.length)) {
                return;
            }
            this.raiseError(`No completions at position '${this.currentCaretPosition}'.`);
        }

        if (actualCompletions.isNewIdentifierLocation !== (options.isNewIdentifierLocation || false)) {
            this.raiseError(`Expected 'isNewIdentifierLocation' to be ${options.isNewIdentifierLocation || false}, got ${actualCompletions.isNewIdentifierLocation}`);
        }

        if (ts.hasProperty(options, "isGlobalCompletion") && actualCompletions.isGlobalCompletion !== options.isGlobalCompletion) {
            this.raiseError(`Expected 'isGlobalCompletion' to be ${options.isGlobalCompletion}, got ${actualCompletions.isGlobalCompletion}`);
        }

        if (ts.hasProperty(options, "optionalReplacementSpan")) {
            assert.deepEqual(
                actualCompletions.optionalReplacementSpan && actualCompletions.optionalReplacementSpan,
                options.optionalReplacementSpan && ts.createTextSpanFromRange(options.optionalReplacementSpan),
                "Expected 'optionalReplacementSpan' properties to match",
            );
        }

        if (ts.hasProperty(options, "defaultCommitCharacters")) {
            assert.deepEqual(
                actualCompletions.defaultCommitCharacters?.slice().sort(),
                options.defaultCommitCharacters?.slice().sort(),
                "Expected 'defaultCommitCharacters' properties to match",
            );
        }

        const nameToEntries = new Map<string, ts.CompletionEntry[]>();
        const nameAndSourceToData = new Map<string, ts.CompletionEntryData | false>();
        for (const entry of actualCompletions.entries) {
            const entries = nameToEntries.get(entry.name);
            if (!entries) {
                nameToEntries.set(entry.name, [entry]);
            }
            else {
                if (
                    entries.some(e =>
                        e.source === entry.source &&
                        e.data?.exportName === entry.data?.exportName &&
                        e.data?.fileName === entry.data?.fileName &&
                        e.data?.moduleSpecifier === entry.data?.moduleSpecifier &&
                        e.data?.ambientModuleName === entry.data?.ambientModuleName
                    )
                ) {
                    this.raiseError(`Duplicate completions for ${entry.name}`);
                }
                entries.push(entry);
            }
            if (entry.data && entry.source) {
                const key = `${entry.name}|${entry.source}`;
                if (nameAndSourceToData.has(key)) {
                    nameAndSourceToData.set(key, false);
                }
                else {
                    nameAndSourceToData.set(key, entry.data);
                }
            }
        }

        if (ts.hasProperty(options, "exact")) {
            ts.Debug.assert(!ts.hasProperty(options, "includes") && !ts.hasProperty(options, "excludes") && !ts.hasProperty(options, "unsorted"));
            if (options.exact === undefined) throw this.raiseError("Expected no completions");
            this.verifyCompletionsAreExactly(actualCompletions.entries, options.exact, options.marker);
        }
        else if (options.unsorted) {
            ts.Debug.assert(!ts.hasProperty(options, "includes") && !ts.hasProperty(options, "excludes"));
            for (const expectedEntry of options.unsorted) {
                const name = typeof expectedEntry === "string" ? expectedEntry : expectedEntry.name;
                const found = nameToEntries.get(name);
                if (!found) throw this.raiseError(`Unsorted: completion '${name}' not found.`);
                if (!found.length) throw this.raiseError(`Unsorted: no completions with name '${name}' remain unmatched.`);
                this.verifyCompletionEntry(found.shift()!, expectedEntry);
            }
            if (actualCompletions.entries.length !== options.unsorted.length) {
                const unmatched: string[] = [];
                nameToEntries.forEach(entries => {
                    unmatched.push(...entries.map(e => e.name));
                });
                this.raiseError(`Additional completions found not included in 'unsorted': ${unmatched.join("\n")}`);
            }
        }
        else {
            if (options.includes) {
                for (const include of toArray(options.includes)) {
                    const name = typeof include === "string" ? include : include.name;
                    const found = nameToEntries.get(name);
                    if (!found) throw this.raiseError(`Includes: completion '${name}' not found.`);
                    if (!found.length) throw this.raiseError(`Includes: no completions with name '${name}' remain unmatched.`);
                    this.verifyCompletionEntry(found.shift()!, include);
                }
            }
            if (options.excludes) {
                for (const exclude of toArray(options.excludes)) {
                    assert(typeof exclude === "string");
                    if (nameToEntries.has(exclude)) {
                        this.raiseError(`Excludes: unexpected completion '${exclude}' found.`);
                    }
                }
            }
        }

        return {
            andApplyCodeAction: (options: {
                name: string;
                source: string;
                description: string;
                newFileContent?: string;
                newRangeContent?: string;
            }) => {
                const { name, source, description, newFileContent, newRangeContent } = options;
                const data = nameAndSourceToData.get(`${options.name}|${options.source}`);
                if (data === false) {
                    this.raiseError(`Multiple completion entries found for '${options.name}' from '${options.source}'. This API cannot be used. Use 'verify.applyCodeActionFromCompletion' instead.`);
                }
                if (data === undefined) {
                    this.raiseError(`No completion entry found for '${options.name}' from '${options.source}'`);
                }
                this.applyCodeActionFromCompletion(/*markerName*/ undefined, { name, source, data, description, newFileContent, newRangeContent, preferences });
            },
        };
    }

    private verifyCompletionEntry(actual: ts.CompletionEntry, expected: FourSlashInterface.ExpectedCompletionEntry) {
        expected = typeof expected === "string" ? { name: expected } : expected;

        if (actual.insertText !== expected.insertText) {
            this.raiseError(`At entry ${actual.name}: Completion insert text did not match: ${showTextDiff(expected.insertText || "", actual.insertText || "")}`);
        }

        const convertedReplacementSpan = expected.replacementSpan && ts.createTextSpanFromRange(expected.replacementSpan);
        if (convertedReplacementSpan) {
            try {
                assert.deepEqual(actual.replacementSpan, convertedReplacementSpan);
            }
            catch {
                this.raiseError(`At entry ${actual.name}: Expected completion replacementSpan to be ${stringify(convertedReplacementSpan)}, got ${stringify(actual.replacementSpan)}`);
            }
        }
        else if (ts.hasProperty(expected, "replacementSpan")) { // Expected `replacementSpan` is explicitly set as `undefined`.
            assert.equal(actual.replacementSpan, undefined, `At entry ${actual.name}: Expected 'replacementSpan' properties to match`);
        }

        if (expected.kind !== undefined || expected.kindModifiers !== undefined) {
            assert.equal(actual.kind, expected.kind, `At entry ${actual.name}: Expected 'kind' for ${actual.name} to match`);
            assert.equal(actual.kindModifiers, expected.kindModifiers || "", `At entry ${actual.name}:  Expected 'kindModifiers' for ${actual.name} to match`);
        }
        if (expected.isFromUncheckedFile !== undefined) {
            assert.equal<boolean | undefined>(actual.isFromUncheckedFile, expected.isFromUncheckedFile, `At entry ${actual.name}: Expected 'isFromUncheckedFile' properties to match`);
        }
        if (expected.isPackageJsonImport !== undefined) {
            assert.equal<boolean | undefined>(actual.isPackageJsonImport, expected.isPackageJsonImport, `At entry ${actual.name}: Expected 'isPackageJsonImport' properties to match`);
        }

        assert.equal(
            actual.filterText,
            expected.filterText,
            `At entry ${actual.name}: Completion 'filterText' not match: ${showTextDiff(expected.filterText || "", actual.filterText || "")}`,
        );
        assert.equal(
            actual.labelDetails?.description,
            expected.labelDetails?.description,
            `At entry ${actual.name}: Completion 'labelDetails.description' did not match: ${showTextDiff(expected.labelDetails?.description || "", actual.labelDetails?.description || "")}`,
        );
        assert.equal(
            actual.labelDetails?.detail,
            expected.labelDetails?.detail,
            `At entry ${actual.name}: Completion 'labelDetails.detail' did not match: ${showTextDiff(expected.labelDetails?.detail || "", actual.labelDetails?.detail || "")}`,
        );
        assert.equal(actual.hasAction, expected.hasAction, `At entry ${actual.name}: Expected 'hasAction' properties to match`);
        assert.equal(actual.isRecommended, expected.isRecommended, `At entry ${actual.name}: Expected 'isRecommended' properties to match'`);
        assert.equal(actual.isSnippet, expected.isSnippet, `At entry ${actual.name}: Expected 'isSnippet' properties to match`);
        assert.equal(actual.source, expected.source, `At entry ${actual.name}: Expected 'source' values to match`);
        assert.equal(actual.sortText, expected.sortText || ts.Completions.SortText.LocationPriority, `At entry ${actual.name}: Expected 'sortText' properties to match`);
        if (ts.hasProperty(expected, "commitCharacters")) {
            assert.deepEqual(
                actual.commitCharacters?.slice().sort(),
                expected.commitCharacters?.slice().sort(),
                `At entry ${actual.name}: Expected 'commitCharacters' values to match`,
            );
        }
        if (expected.sourceDisplay && actual.sourceDisplay) {
            assert.equal(ts.displayPartsToString(actual.sourceDisplay), expected.sourceDisplay, `At entry ${actual.name}: Expected 'sourceDisplay' properties to match`);
        }

        if (expected.text !== undefined) {
            const actualDetails = ts.Debug.checkDefined(this.getCompletionEntryDetails(actual.name, actual.source, actual.data), `No completion details available for name '${actual.name}' and source '${actual.source}'`);
            assert.equal(ts.displayPartsToString(actualDetails.displayParts), expected.text, "Expected 'text' property to match 'displayParts' string");
            assert.equal(ts.displayPartsToString(actualDetails.documentation), expected.documentation || "", "Expected 'documentation' property to match 'documentation' display parts string");
            // TODO: GH#23587
            // assert.equal(actualDetails.kind, actual.kind);
            assert.equal(actualDetails.kindModifiers, actual.kindModifiers, "Expected 'kindModifiers' properties to match");
            assert.equal(actualDetails.source && ts.displayPartsToString(actualDetails.source), expected.sourceDisplay, "Expected 'sourceDisplay' property to match 'source' display parts string");
            if (!actual.sourceDisplay) {
                assert.equal(actualDetails.sourceDisplay && ts.displayPartsToString(actualDetails.sourceDisplay), expected.sourceDisplay, "Expected 'sourceDisplay' property to match 'sourceDisplay' display parts string");
            }
            assert.deepEqual(actualDetails.tags, expected.tags);
        }
        else {
            assert(expected.documentation === undefined && expected.tags === undefined, "If specifying completion details, should specify 'text'");
        }
    }

    private verifyCompletionsAreExactly(actual: readonly ts.CompletionEntry[], expected: ArrayOrSingle<FourSlashInterface.ExpectedCompletionEntry> | FourSlashInterface.ExpectedExactCompletionsPlus, marker?: ArrayOrSingle<string | Marker>) {
        if (!ts.isArray(expected)) {
            expected = [expected];
        }

        // First pass: test that names are right. Then we'll test details.
        assert.deepEqual(actual.map(a => a.name), expected.map(e => typeof e === "string" ? e : e.name), marker ? "At marker " + JSON.stringify(marker) : undefined);

        ts.zipWith(actual, expected, (completion, expectedCompletion, index) => {
            const name = typeof expectedCompletion === "string" ? expectedCompletion : expectedCompletion.name;
            if (completion.name !== name) {
                this.raiseError(`${marker ? JSON.stringify(marker) : ""} Expected completion at index ${index} to be ${name}, got ${completion.name}`);
            }
            this.verifyCompletionEntry(completion, expectedCompletion);
        });

        // All completions were correct in the sort order given. If that order was produced by a function
        // like `completion.globalsPlus`, ensure the "plus" array was sorted in the same way.
        const { plusArgument, plusFunctionName } = expected as FourSlashInterface.ExpectedExactCompletionsPlus;
        if (plusArgument) {
            assert.deepEqual(
                plusArgument,
                expected.filter(entry => plusArgument.includes(entry)),
                `At marker ${JSON.stringify(marker)}: Argument to '${plusFunctionName}' was incorrectly sorted.`,
            );
        }
    }

    /** Use `getProgram` instead of accessing this directly. */
    private _program: ts.Program | undefined | "missing";
    /** Use `getChecker` instead of accessing this directly. */
    private _checker: ts.TypeChecker | undefined;

    private getProgram(): ts.Program {
        if (!this._program) this._program = this.languageService.getProgram() || "missing";
        if (this._program === "missing") ts.Debug.fail("Could not retrieve program from language service");
        return this._program;
    }

    private getChecker() {
        return this._checker || (this._checker = this.getProgram().getTypeChecker());
    }

    private getSourceFile(): ts.SourceFile {
        const { fileName } = this.activeFile;
        const result = this.getProgram().getSourceFile(fileName);
        if (!result) {
            throw new Error(`Could not get source file ${fileName}`);
        }
        return result;
    }

    private getNode(): ts.Node {
        return ts.getTouchingPropertyName(this.getSourceFile(), this.currentCaretPosition);
    }

    private goToAndGetNode(range: Range): ts.Node {
        this.goToRangeStart(range);
        const node = this.getNode();
        this.verifyRange("touching property name", range, node);
        return node;
    }

    private verifyRange(desc: string, expected: ts.TextRange, actual: ts.Node) {
        const actualStart = actual.getStart();
        const actualEnd = actual.getEnd();
        if (actualStart !== expected.pos || actualEnd !== expected.end) {
            this.raiseError(`${desc} should be ${expected.pos}-${expected.end}, got ${actualStart}-${actualEnd}`);
        }
    }

    private verifySymbol(symbol: ts.Symbol, declarationRanges: Range[]) {
        const { declarations } = symbol;
        if (declarations?.length !== declarationRanges.length) {
            this.raiseError(`Expected to get ${declarationRanges.length} declarations, got ${declarations?.length}`);
        }

        ts.zipWith(declarations, declarationRanges, (decl, range) => {
            this.verifyRange("symbol declaration", range, decl);
        });
    }

    public verifySymbolAtLocation(startRange: Range, declarationRanges: Range[]): void {
        const node = this.goToAndGetNode(startRange);
        const symbol = this.getChecker().getSymbolAtLocation(node)!;
        if (!symbol) {
            this.raiseError("Could not get symbol at location");
        }
        this.verifySymbol(symbol, declarationRanges);
    }

    public symbolsInScope(range: Range): ts.Symbol[] {
        const node = this.goToAndGetNode(range);
        return this.getChecker().getSymbolsInScope(node, ts.SymbolFlags.Value | ts.SymbolFlags.Type | ts.SymbolFlags.Namespace);
    }

    public setTypesRegistry(map: ts.MapLike<void>): void {
        this.languageServiceAdapterHost.typesRegistry = new Map(Object.entries(map));
    }

    public verifyTypeOfSymbolAtLocation(range: Range, symbol: ts.Symbol, expected: string): void {
        const node = this.goToAndGetNode(range);
        const checker = this.getChecker();
        const type = checker.getTypeOfSymbolAtLocation(symbol, node);

        const actual = checker.typeToString(type);
        if (actual !== expected) {
            this.raiseError(displayExpectedAndActualString(expected, actual));
        }
    }

    public verifyTypeAtLocation(range: Range, expected: string): void {
        const node = this.goToAndGetNode(range);
        const checker = this.getChecker();
        const type = checker.getTypeAtLocation(node);

        const actual = checker.typeToString(type);
        if (actual !== expected) {
            this.raiseError(displayExpectedAndActualString(expected, actual));
        }
    }

    private baselineEachMarkerOrRangeArrayOrSingle(
        command: string,
        markerOrRange: ArrayOrSingle<MarkerOrNameOrRange> | undefined,
        rangeText: ArrayOrSingle<string> | undefined,
        worker: (markerORange: MarkerOrNameOrRange) => string,
    ) {
        return this.baselineEachMarkerOrRange(
            command,
            markerOrRange !== undefined ? toArray(markerOrRange) : undefined,
            rangeText !== undefined ? toArray(rangeText) : undefined,
            worker,
        );
    }

    private baselineEachMarkerOrRange(
        command: string,
        markerOrRange: readonly MarkerOrNameOrRange[] | undefined,
        rangeText: readonly string[] | undefined,
        worker: (markerORange: MarkerOrNameOrRange) => string,
    ) {
        let done = false;
        if (markerOrRange !== undefined) {
            done = this.baselineArray(command, markerOrRange, worker);
        }
        if (rangeText !== undefined) {
            toArray(rangeText).forEach(text => done = this.baselineArray(command, this.rangesByText().get(text)!, worker) || done);
        }
        if (!done) this.baselineArray(command, this.getRanges(), worker);
    }

    private baselineArray<T>(
        command: string,
        array: readonly T[],
        worker: (single: T) => string,
    ) {
        array.forEach(single => this.baseline(command, worker(single), ".baseline.jsonc"));
        return !!array.length;
    }

    public baselineFindAllReferences(
        markerOrRange: MarkerOrNameOrRange[] | undefined,
        rangeText: string[] | undefined,
    ): void {
        this.baselineEachMarkerOrRange("findAllReferences", markerOrRange, rangeText, markerOrRange => {
            this.goToMarkerOrNameOrRange(markerOrRange);
            const references = this.findReferencesAtCaret();
            const defIdMap = new Map<ts.ReferencedSymbolDefinitionInfo | ts.ReferencedSymbolEntry, number>();
            const markerInfo = { markerOrRange, markerName: "/*FIND ALL REFS*/" };
            let baseline = this.getBaselineForDocumentSpansWithFileContents(
                ts.flatMap(references, (r, def) => {
                    if (references!.length > 1) {
                        defIdMap.set(r.definition, def);
                        r.references.forEach(r => defIdMap.set(r, def));
                    }
                    return r.references;
                }),
                {
                    markerInfo,
                    documentSpanId: defIdMap.size ? ref => `defId: ${defIdMap.get(ref)}` : undefined,
                },
            );
            if (references?.length) {
                baseline += "\n\n";
                baseline += indentJsonBaseline(
                    "// === Definitions ===\n" +
                        this.getBaselineForDocumentSpansWithFileContents(
                            references.map(r => r.definition),
                            {
                                markerInfo,
                                documentSpanId: defIdMap.size ? def => `defId: ${defIdMap.get(def)}` : undefined,
                                skipDocumentSpanDetails: true,
                                skipDocumentContainingOnlyMarker: true,
                            },
                        ) +
                        "\n\n// === Details ===\n" +
                        JSON.stringify(
                            references.map(r => ({
                                defId: defIdMap.get(r.definition),
                                ...r.definition,
                                fileName: undefined,
                                textSpan: undefined,
                                contextSpan: undefined,
                            })),
                            undefined,
                            " ",
                        ),
                );
            }
            return baseline;
        });
    }

    public baselineGetFileReferences(fileNames: string[]): void {
        this.baselineArray("getFileReferences", fileNames, fileName => {
            const references = this.languageService.getFileReferences(fileName);
            return `// fileName: ${fileName}\n\n` + this.getBaselineForDocumentSpansWithFileContents(
                references,
                { markerInfo: undefined },
            );
        });
    }

    private getBaselineForDocumentSpansWithFileContents<T extends ts.DocumentSpan>(
        spans: readonly T[] | undefined,
        options: BaselineDocumentSpansWithFileContentsOptions<T>,
    ): string {
        // Write input files
        return this.getBaselineForGroupedDocumentSpansWithFileContents(
            spans ? ts.group(spans, span => span.fileName) : ts.emptyArray,
            options,
        );
    }

    private getBaselineForGroupedDocumentSpansWithFileContents<T extends ts.DocumentSpan>(
        spansByFile: readonly (readonly T[])[],
        options: BaselineDocumentSpansWithFileContentsOptions<T>,
    ) {
        const {
            markerInfo,
            documentSpanId,
            skipDocumentSpanDetails,
            skipDocumentContainingOnlyMarker,
            additionalSpan,
        } = options;
        const marker: Marker | undefined = markerInfo !== undefined ?
            ts.isString(markerInfo.markerOrRange) ?
                this.getMarkerByName(markerInfo.markerOrRange) :
                isMarker(markerInfo.markerOrRange) ?
                markerInfo.markerOrRange :
                { fileName: markerInfo.markerOrRange.fileName, position: markerInfo.markerOrRange.pos } :
            undefined;
        const fileBaselines: string[] = [];
        let foundMarker = false;
        let foundAdditionalSpan = false;
        const spanToContextId = new Map<T, number>();
        for (const group of spansByFile) {
            if (group.length) {
                const contentOfFile = this.tryGetFileContent(group[0].fileName);
                if (contentOfFile !== undefined) {
                    fileBaselines.push(this.getBaselineContentForFile(
                        group[0].fileName,
                        contentOfFile,
                        group,
                        marker,
                        options,
                        spanToContextId,
                    ));
                    foundMarker ||= group[0].fileName === marker?.fileName;
                    foundAdditionalSpan ||= !!additionalSpan && additionalSpan.fileName === group[0].fileName;
                }
                else {
                    let baseline = `// === ${group[0].fileName} ===\n// Unavailable file content:\n`;
                    for (const span of group) {
                        baseline += `// textSpan: ${JSON.stringify(span.textSpan)}${span.contextSpan ? `, contextSpan: ${JSON.stringify(span.contextSpan)}` : ""}`;
                        const text = !skipDocumentSpanDetails ?
                            convertDocumentSpanToString(span, documentSpanId?.(span)) :
                            documentSpanId?.(span);
                        if (text) baseline += ` ${text}`;
                        baseline += "\n";
                    }
                    fileBaselines.push(baseline);
                }
            }
        }
        if (additionalSpan && !foundAdditionalSpan) {
            fileBaselines.push(this.getBaselineContentForFile(
                additionalSpan.fileName,
                this.getFileContent(additionalSpan.fileName),
                [additionalSpan],
                marker,
                { markerInfo },
                spanToContextId,
            ));
            foundMarker ||= additionalSpan.fileName === marker?.fileName;
        }
        if (!skipDocumentContainingOnlyMarker && !foundMarker && marker?.fileName) {
            fileBaselines.push(this.getBaselineContentForFile(
                marker.fileName,
                this.getFileContent(marker.fileName),
                ts.emptyArray,
                marker,
                { markerInfo },
                spanToContextId,
            ));
        }
        return fileBaselines.join("\n\n");
    }

    private static readonly nLinesContext = 4;

    private getBaselineContentForFile<T extends ts.DocumentSpan>(
        fileName: string,
        content: string,
        group: readonly T[],
        marker: Marker | undefined,
        {
            markerInfo,
            documentSpanId,
            skipDocumentSpanDetails,
            endMarker,
            startMarkerPrefix,
            endMarkerSuffix,
            ignoredDocumentSpanProperties,
            additionalSpan,
        }: BaselineDocumentSpansWithFileContentsOptions<T>,
        spanToContextId: Map<T, number>,
    ) {
        const isLibFile = /lib.*\.d\.ts$/.test(fileName);
        let readableContents = `// === ${fileName} ===`;
        let newContent = "";
        interface Detail {
            location: number;
            locationMarker: string;
            span?: T;
            type?: "textStart" | "textEnd" | "contextStart" | "contextEnd";
        }
        const detailPrefixes = new Map<Detail, string>();
        const detailSuffixes = new Map<Detail, string>();
        const details: Detail[] = [];
        let groupedSpanForAdditionalSpan: T | undefined;
        if (fileName === marker?.fileName) details.push({ location: marker.position, locationMarker: markerInfo!.markerName });
        let canDetermineContextIdInline = true;
        for (const span of group) {
            const contextSpanIndex = details.length;
            if (span.contextSpan) {
                details.push({ location: span.contextSpan.start, locationMarker: "<|", span, type: "contextStart" });
                if (canDetermineContextIdInline && span.contextSpan.start > span.textSpan.start) {
                    // Need to do explicit pass to determine contextId since contextId starts after textStart
                    canDetermineContextIdInline = false;
                }
            }
            const textSpanIndex = details.length;
            const textSpanEnd = ts.textSpanEnd(span.textSpan);
            details.push(
                { location: span.textSpan.start, locationMarker: "[|", span, type: "textStart" },
                { location: textSpanEnd, locationMarker: endMarker || "|]", span, type: "textEnd" },
            );
            let contextSpanEnd: number | undefined;
            if (span.contextSpan) {
                contextSpanEnd = ts.textSpanEnd(span.contextSpan);
                details.push({ location: contextSpanEnd, locationMarker: "|>", span, type: "contextEnd" });
            }

            if (additionalSpan && ts.documentSpansEqual(additionalSpan, span, this.languageServiceAdapterHost.useCaseSensitiveFileNames())) {
                // This span is same as text span
                groupedSpanForAdditionalSpan = span;
            }

            const startPrefix = startMarkerPrefix?.(span);
            if (startPrefix) {
                if (fileName === marker?.fileName && span.textSpan.start === marker?.position) {
                    ts.Debug.assert(!detailPrefixes.has(details[0]), "Expected only single prefix at marker location");
                    detailPrefixes.set(details[0], startPrefix);
                }
                else if (span.contextSpan?.start === span.textSpan.start) {
                    // Write it at contextSpan instead of textSpan
                    detailPrefixes.set(details[contextSpanIndex], startPrefix);
                }
                else {
                    // At textSpan
                    detailPrefixes.set(details[textSpanIndex], startPrefix);
                }
            }

            const endSuffix = endMarkerSuffix?.(span);
            if (endSuffix) {
                if (fileName === marker?.fileName && textSpanEnd === marker?.position) {
                    ts.Debug.assert(!detailSuffixes.has(details[0]), "Expected only single suffix at marker location");
                    detailSuffixes.set(details[0], endSuffix);
                }
                else if (contextSpanEnd === textSpanEnd) {
                    // Write it at contextSpan instead of textSpan
                    detailSuffixes.set(details[textSpanIndex + 2], endSuffix);
                }
                else {
                    // At textSpan
                    detailSuffixes.set(details[textSpanIndex + 1], endSuffix);
                }
            }
        }
        let pos = 0;
        const sortedDetails = ts.toSorted(details, (a, b) => ts.compareValues(a.location, b.location));
        if (!canDetermineContextIdInline) {
            // Assign contextIds
            sortedDetails.forEach(({ span, type }) => {
                if (type === "contextStart") {
                    spanToContextId.set(span!, spanToContextId.size);
                }
            });
        }
        const lineStarts = ts.computeLineStarts(content);
        let posLineInfo: { pos: number; line: number; } | undefined;
        // Our preferred way to write marker is
        // /*MARKER*/[| some text |]
        // [| some /*MARKER*/ text |]
        // [| some text |]/*MARKER*/
        // Stable sort should handle first two cases but with that marker will be before rangeEnd if locations match
        // So we will defer writing marker in this case by checking and finding index of rangeEnd if same
        let deferredMarkerIndex: number | undefined;
        sortedDetails.forEach((detail, index) => {
            const { location, locationMarker, span, type } = detail;
            if (!span && deferredMarkerIndex === undefined) {
                // If this is marker position and its same as textEnd and/or contextEnd we want to write marker after those
                for (let matchingEndPosIndex = index + 1; matchingEndPosIndex < sortedDetails.length; matchingEndPosIndex++) {
                    // Defer after the location if its same as rangeEnd
                    if (
                        sortedDetails[matchingEndPosIndex].location === location &&
                        sortedDetails[matchingEndPosIndex].type!.endsWith("End")
                    ) {
                        deferredMarkerIndex = matchingEndPosIndex;
                    }
                    // Dont defer further than already determined
                    break;
                }
                // Defer writing marker position to deffered marker index
                if (deferredMarkerIndex !== undefined) return;
            }
            textWithContext(location, type);
            pos = location;
            // Prefix
            const prefix = detailPrefixes.get(detail);
            if (prefix) newContent += prefix;
            newContent += locationMarker;
            if (span) {
                switch (type) {
                    case "textStart":
                        let text = !skipDocumentSpanDetails ?
                            convertDocumentSpanToString(span, documentSpanId?.(span), ignoredDocumentSpanProperties) :
                            documentSpanId?.(span);
                        if (span === groupedSpanForAdditionalSpan) {
                            text = `textSpan: true` + (text ? `, ${text}` : "");
                        }
                        const contextId = spanToContextId.get(span);
                        if (contextId !== undefined) {
                            let isAfterContextStart = false;
                            for (let textStartIndex = index - 1; textStartIndex >= 0; textStartIndex--) {
                                const textStartDetail = sortedDetails[textStartIndex];
                                if (textStartDetail.type === "contextStart" && textStartDetail.span === span) {
                                    isAfterContextStart = true;
                                    break;
                                }
                                // Marker is ok to skip over
                                if (textStartDetail.span) break;
                            }
                            // Skip contextId on span thats surrounded by context span immediately
                            if (!isAfterContextStart) {
                                text = `contextId: ${contextId}` + (text ? `, ${text}` : "");
                            }
                        }
                        if (text) newContent += `{| ${text} |}`;
                        break;
                    case "contextStart":
                        if (canDetermineContextIdInline) {
                            spanToContextId.set(span, spanToContextId.size);
                        }
                        break;
                }
                if (deferredMarkerIndex === index) {
                    // Write the marker
                    newContent += markerInfo!.markerName;
                    deferredMarkerIndex = undefined;
                    detail = details[0]; // Marker detail
                }
            }
            const suffix = detailSuffixes.get(detail);
            if (suffix) newContent += suffix;
        });
        textWithContext(/*location*/ undefined, /*type*/ undefined);
        return readableContents + (newContent ? "\n" + readableJsoncBaseline(newContent) : "");

        function textWithContext(location: number | undefined, type: Detail["type"]) {
            if (!newContent && location === undefined) ts.Debug.fail("Unsupported");
            if (type !== "textEnd" && type !== "contextEnd") {
                // Calculate pos to location number of lines
                const posLine = posLineInfo?.pos === pos ? posLineInfo.line : ts.computeLineOfPosition(lineStarts, pos, posLineInfo?.line);
                const locationLine = location !== undefined ? ts.computeLineOfPosition(lineStarts, location, posLine) : lineStarts.length - 1;
                if (location !== undefined) posLineInfo = { pos: location, line: locationLine };
                let nLines = 0;
                if (newContent) nLines += TestState.nLinesContext + 1;
                if (location !== undefined) nLines += TestState.nLinesContext + 1;
                // first nLinesContext and last nLinesContext
                if (locationLine - posLine > nLines) {
                    if (newContent) {
                        readableContents = readableContents + "\n" + readableJsoncBaseline(
                            newContent + content.slice(pos, lineStarts[posLine + TestState.nLinesContext]) +
                                `--- (line: ${isLibFile ? "--" : posLine + TestState.nLinesContext + 1}) skipped ---`,
                        );
                        if (location !== undefined) readableContents += "\n";
                        newContent = "";
                    }
                    if (location !== undefined) {
                        newContent += `--- (line: ${isLibFile ? "--" : locationLine - TestState.nLinesContext + 1}) skipped ---\n` +
                            content.slice(lineStarts[locationLine - TestState.nLinesContext + 1], location);
                    }
                    return;
                }
            }
            newContent += content.slice(pos, location);
        }
    }

    private assertObjectsEqual<T>(fullActual: T, fullExpected: T, msgPrefix = ""): void {
        const recur = <U>(actual: U, expected: U, path: string) => {
            const fail = (msg: string) => {
                this.raiseError(`${msgPrefix} At ${path}: ${msg} ${displayExpectedAndActualString(stringify(fullExpected), stringify(fullActual))}`);
            };

            if ((actual === undefined) !== (expected === undefined)) {
                fail(`Expected ${stringify(expected)}, got ${stringify(actual)}`);
            }

            for (const key in actual) {
                if (ts.hasProperty(actual as any, key)) {
                    const ak = actual[key], ek = expected[key];
                    if (typeof ak === "object" && typeof ek === "object") {
                        recur(ak, ek, path ? path + "." + key : key);
                    }
                    else if (ak !== ek) {
                        fail(`Expected '${key}' to be '${stringify(ek)}', got '${stringify(ak)}'`);
                    }
                }
            }

            for (const key in expected) {
                if (ts.hasProperty(expected as any, key)) {
                    if (!ts.hasProperty(actual as any, key)) {
                        fail(`${msgPrefix}Missing property '${key}'`);
                    }
                }
            }
        };

        if (fullActual === undefined || fullExpected === undefined) {
            if (fullActual === fullExpected) {
                return;
            }
            this.raiseError(`${msgPrefix} ${displayExpectedAndActualString(stringify(fullExpected), stringify(fullActual))}`);
        }
        recur(fullActual, fullExpected, "");
    }

    private configure(preferences: ts.UserPreferences) {
        if (this.testType === FourSlashTestType.Server) {
            (this.languageService as ts.server.SessionClient).configure(preferences);
        }
    }

    private getCompletionListAtCaret(options?: ts.GetCompletionsAtPositionOptions): ts.CompletionInfo | undefined {
        if (options) {
            this.configure(options);
        }
        return this.languageService.getCompletionsAtPosition(
            this.activeFile.fileName,
            this.currentCaretPosition,
            options,
            this.formatCodeSettings,
        );
    }

    private getCompletionEntryDetails(entryName: string, source: string | undefined, data: ts.CompletionEntryData | undefined, preferences?: ts.UserPreferences): ts.CompletionEntryDetails | undefined {
        if (preferences) {
            this.configure(preferences);
        }
        return this.languageService.getCompletionEntryDetails(this.activeFile.fileName, this.currentCaretPosition, entryName, this.formatCodeSettings, source, preferences, data);
    }

    private findReferencesAtCaret() {
        return this.languageService.findReferences(this.activeFile.fileName, this.currentCaretPosition);
    }

    public getSyntacticDiagnostics(expected: readonly FourSlashInterface.Diagnostic[]): void {
        const diagnostics = this.languageService.getSyntacticDiagnostics(this.activeFile.fileName);
        this.testDiagnostics(expected, diagnostics, "error");
    }

    public getSemanticDiagnostics(): ts.Diagnostic[] {
        return this.languageService.getSemanticDiagnostics(this.activeFile.fileName);
    }

    public verifySemanticDiagnostics(expected: readonly FourSlashInterface.Diagnostic[]): void {
        const diagnostics = this.getSemanticDiagnostics();
        this.testDiagnostics(expected, diagnostics, "error");
    }

    public getSuggestionDiagnostics(expected: readonly FourSlashInterface.Diagnostic[]): void {
        this.testDiagnostics(expected, this.languageService.getSuggestionDiagnostics(this.activeFile.fileName), "suggestion");
    }

    public getRegionSemanticDiagnostics(
        ranges: ts.TextRange[],
        expectedDiagnostics: readonly FourSlashInterface.Diagnostic[] | undefined,
        expectedRanges: ts.TextRange[] | undefined,
    ): void {
        const diagnosticsResult = this.languageService.getRegionSemanticDiagnostics(this.activeFile.fileName, ranges);
        if (diagnosticsResult && expectedDiagnostics) {
            this.testDiagnostics(expectedDiagnostics, diagnosticsResult.diagnostics, "error");
        }
        else if (diagnosticsResult !== expectedDiagnostics) {
            if (expectedDiagnostics) this.raiseError("Expected diagnostics to be defined.");
            else {assert.deepEqual(
                    diagnosticsResult!.diagnostics,
                    expectedDiagnostics,
                    "Expected diagnostics to be undefined.",
                );}
        }

        if (expectedRanges && diagnosticsResult) {
            const spans = expectedRanges.map(range => ({ start: range.pos, length: range.end - range.pos }));
            assert.deepEqual(diagnosticsResult.spans, spans);
        }
        else if (expectedRanges && !diagnosticsResult) {
            this.raiseError("Expected spans to be defined.");
        }
    }

    private testDiagnostics(expected: readonly FourSlashInterface.Diagnostic[], diagnostics: readonly ts.Diagnostic[], category: string) {
        assert.deepEqual(
            realizeDiagnostics(diagnostics, "\n"),
            expected.map((e): RealizedDiagnostic => {
                const range = e.range || this.getRangesInFile()[0];
                if (!range) {
                    this.raiseError("Must provide a range for each expected diagnostic, or have one range in the fourslash source.");
                }
                return {
                    message: e.message,
                    category,
                    code: e.code,
                    ...ts.createTextSpanFromRange(range),
                    reportsUnnecessary: e.reportsUnnecessary,
                    reportsDeprecated: e.reportsDeprecated,
                };
            }),
        );
    }

    public verifyQuickInfoAt(markerName: string | Range, expectedText: string, expectedDocumentation?: string, expectedTags?: { name: string; text: string; }[]): void {
        if (typeof markerName === "string") this.goToMarker(markerName);
        else this.goToRangeStart(markerName);

        this.verifyQuickInfoString(expectedText, expectedDocumentation, expectedTags);
    }

    public verifyQuickInfos(namesAndTexts: { [name: string]: string | [string, string]; }): void {
        for (const name in namesAndTexts) {
            if (ts.hasProperty(namesAndTexts, name)) {
                const text = namesAndTexts[name];
                if (ts.isArray(text)) {
                    assert(text.length === 2);
                    const [expectedText, expectedDocumentation] = text;
                    this.verifyQuickInfoAt(name, expectedText, expectedDocumentation);
                }
                else {
                    this.verifyQuickInfoAt(name, text);
                }
            }
        }
    }

    public verifyQuickInfoString(expectedText: string, expectedDocumentation?: string, expectedTags?: { name: string; text: string; }[]): void {
        if (expectedDocumentation === "") {
            throw new Error("Use 'undefined' instead of empty string for `expectedDocumentation`");
        }
        const actualQuickInfo = this.languageService.getQuickInfoAtPosition(this.activeFile.fileName, this.currentCaretPosition);
        const actualQuickInfoText = ts.displayPartsToString(actualQuickInfo?.displayParts);
        const actualQuickInfoDocumentation = ts.displayPartsToString(actualQuickInfo?.documentation);
        const actualQuickInfoTags = actualQuickInfo?.tags?.map(tag => ({ name: tag.name, text: ts.displayPartsToString(tag.text) }));

        assert.equal(actualQuickInfoText, expectedText, this.messageAtLastKnownMarker("quick info text"));
        assert.equal(actualQuickInfoDocumentation, expectedDocumentation || "", this.assertionMessageAtLastKnownMarker("quick info doc"));
        if (!expectedTags) {
            // Skip if `expectedTags` is not given
        }
        else if (!actualQuickInfoTags) {
            assert.equal(actualQuickInfoTags, expectedTags, this.messageAtLastKnownMarker("QuickInfo tags"));
        }
        else {
            ts.zipWith(expectedTags, actualQuickInfoTags, (expectedTag, actualTag) => {
                assert.equal(expectedTag.name, actualTag.name);
                assert.equal(expectedTag.text, actualTag.text, this.messageAtLastKnownMarker("QuickInfo tag " + actualTag.name));
            });
        }
    }

    public verifyQuickInfoDisplayParts(kind: string, kindModifiers: string, textSpan: TextSpan, displayParts: ts.SymbolDisplayPart[], documentation: ts.SymbolDisplayPart[], tags: ts.JSDocTagInfo[] | undefined): void {
        const actualQuickInfo = this.languageService.getQuickInfoAtPosition(this.activeFile.fileName, this.currentCaretPosition)!;
        assert.equal(actualQuickInfo.kind, kind, this.messageAtLastKnownMarker("QuickInfo kind"));
        assert.equal(actualQuickInfo.kindModifiers, kindModifiers, this.messageAtLastKnownMarker("QuickInfo kindModifiers"));
        assert.equal(JSON.stringify(actualQuickInfo.textSpan), JSON.stringify(textSpan), this.messageAtLastKnownMarker("QuickInfo textSpan"));
        assert.equal(TestState.getDisplayPartsJson(actualQuickInfo.displayParts), TestState.getDisplayPartsJson(displayParts), this.messageAtLastKnownMarker("QuickInfo displayParts"));
        assert.equal(TestState.getDisplayPartsJson(actualQuickInfo.documentation), TestState.getDisplayPartsJson(documentation), this.messageAtLastKnownMarker("QuickInfo documentation"));
        if (!actualQuickInfo.tags || !tags) {
            assert.equal(actualQuickInfo.tags, tags, this.messageAtLastKnownMarker("QuickInfo tags"));
        }
        else {
            assert.equal(actualQuickInfo.tags.length, tags.length, this.messageAtLastKnownMarker("QuickInfo tags"));
            ts.zipWith(tags, actualQuickInfo.tags, (expectedTag, actualTag) => {
                assert.equal(expectedTag.name, actualTag.name);
                assert.equal(expectedTag.text, actualTag.text, this.messageAtLastKnownMarker("QuickInfo tag " + actualTag.name));
            });
        }
    }

    public baselineRename(
        markerOrRange: ArrayOrSingle<MarkerOrNameOrRange> | undefined,
        rangeText: ArrayOrSingle<string> | undefined,
        options: FourSlashInterface.RenameOptions | undefined,
    ): void {
        this.baselineEachMarkerOrRangeArrayOrSingle("findRenameLocations", markerOrRange, rangeText, markerOrRange => {
            const { fileName, position } = ts.isString(markerOrRange) ?
                this.getMarkerByName(markerOrRange) :
                isMarker(markerOrRange) ?
                markerOrRange :
                { fileName: markerOrRange.fileName, position: markerOrRange.pos };
            const {
                findInStrings = false,
                findInComments = false,
                providePrefixAndSuffixTextForRename = true,
                quotePreference = "double",
            } = options || {};
            const locations = this.languageService.findRenameLocations(
                fileName,
                position,
                findInStrings,
                findInComments,
                { providePrefixAndSuffixTextForRename, quotePreference },
            );

            if (!locations) {
                this.raiseError(`baselineRename failed. Could not rename at the provided position.`);
            }

            const renameOptions = options ?
                (options.findInStrings !== undefined ? `// @findInStrings: ${findInStrings}\n` : "") +
                (options.findInComments !== undefined ? `// @findInComments: ${findInComments}\n` : "") +
                (options.providePrefixAndSuffixTextForRename !== undefined ? `// @providePrefixAndSuffixTextForRename: ${providePrefixAndSuffixTextForRename}\n` : "") +
                (options.quotePreference !== undefined ? `// @quotePreference: ${quotePreference}\n` : "") :
                "";

            return renameOptions + (renameOptions ? "\n" : "") + this.getBaselineForDocumentSpansWithFileContents(
                locations,
                {
                    markerInfo: { markerOrRange, markerName: "/*RENAME*/" },
                    endMarker: "RENAME|]",
                    startMarkerPrefix: span => span.prefixText ? `/*START PREFIX*/${span.prefixText}` : "",
                    endMarkerSuffix: span => span.suffixText ? `${span.suffixText}/*END SUFFIX*/` : "",
                    ignoredDocumentSpanProperties: ["prefixText", "suffixText"],
                },
            );
        });
    }

    public verifyQuickInfoExists(negative: boolean): void {
        const actualQuickInfo = this.languageService.getQuickInfoAtPosition(this.activeFile.fileName, this.currentCaretPosition);
        if (negative) {
            if (actualQuickInfo) {
                this.raiseError("verifyQuickInfoExists failed. Expected quick info NOT to exist");
            }
        }
        else {
            if (!actualQuickInfo) {
                this.raiseError("verifyQuickInfoExists failed. Expected quick info to exist");
            }
        }
    }

    public verifySignatureHelpPresence(expectPresent: boolean, triggerReason: ts.SignatureHelpTriggerReason | undefined, markers: readonly (string | Marker)[]): void {
        if (markers.length) {
            for (const marker of markers) {
                this.goToMarker(marker);
                this.verifySignatureHelpPresence(expectPresent, triggerReason, ts.emptyArray);
            }
            return;
        }
        const actual = this.getSignatureHelp({ triggerReason });
        if (expectPresent !== !!actual) {
            if (actual) {
                this.raiseError(`Expected no signature help, but got "${stringify(actual)}"`);
            }
            else {
                this.raiseError("Expected signature help, but none was returned.");
            }
        }
    }

    public verifySignatureHelp(optionses: readonly FourSlashInterface.VerifySignatureHelpOptions[]): void {
        for (const options of optionses) {
            if (options.marker === undefined) {
                this.verifySignatureHelpWorker(options);
            }
            else {
                for (const marker of toArray(options.marker)) {
                    this.goToMarker(marker);
                    this.verifySignatureHelpWorker(options);
                }
            }
        }
    }

    private verifySignatureHelpWorker(options: FourSlashInterface.VerifySignatureHelpOptions) {
        const help = this.getSignatureHelp({ triggerReason: options.triggerReason })!;
        if (!help) {
            this.raiseError("Could not get a help signature");
        }

        const selectedItem = help.items[options.overrideSelectedItemIndex ?? help.selectedItemIndex];
        // Argument index may exceed number of parameters
        const currentParameter = selectedItem.parameters[help.argumentIndex] as ts.SignatureHelpParameter | undefined;

        assert.equal(help.items.length, options.overloadsCount || 1, this.assertionMessageAtLastKnownMarker("signature help overloads count"));

        assert.equal(ts.displayPartsToString(selectedItem.documentation), options.docComment || "", this.assertionMessageAtLastKnownMarker("current signature help doc comment"));

        if (options.text !== undefined) {
            assert.equal(
                ts.displayPartsToString(selectedItem.prefixDisplayParts) +
                    selectedItem.parameters.map(p => ts.displayPartsToString(p.displayParts)).join(ts.displayPartsToString(selectedItem.separatorDisplayParts)) +
                    ts.displayPartsToString(selectedItem.suffixDisplayParts),
                options.text,
            );
        }
        if (options.parameterName !== undefined) {
            assert.equal(currentParameter!.name, options.parameterName);
        }
        if (options.parameterSpan !== undefined) {
            assert.equal(ts.displayPartsToString(currentParameter!.displayParts), options.parameterSpan);
        }
        if (currentParameter) {
            assert.equal(ts.displayPartsToString(currentParameter.documentation), options.parameterDocComment || "", this.assertionMessageAtLastKnownMarker("current parameter Help DocComment"));
        }
        if (options.parameterCount !== undefined) {
            assert.equal(selectedItem.parameters.length, options.parameterCount);
        }
        if (options.argumentCount !== undefined) {
            assert.equal(help.argumentCount, options.argumentCount);
        }

        assert.equal(selectedItem.isVariadic, !!options.isVariadic);

        const actualTags = selectedItem.tags;
        assert.equal(actualTags.length, (options.tags || ts.emptyArray).length, this.assertionMessageAtLastKnownMarker("signature help tags"));
        ts.zipWith(options.tags || ts.emptyArray, actualTags, (expectedTag, actualTag) => {
            assert.equal(actualTag.name, expectedTag.name);
            assert.deepEqual(actualTag.text, expectedTag.text, this.assertionMessageAtLastKnownMarker("signature help tag " + actualTag.name));
        });

        const allKeys: readonly (keyof FourSlashInterface.VerifySignatureHelpOptions)[] = [
            "marker",
            "triggerReason",
            "overloadsCount",
            "docComment",
            "text",
            "parameterName",
            "parameterSpan",
            "parameterDocComment",
            "parameterCount",
            "isVariadic",
            "tags",
            "argumentCount",
            "overrideSelectedItemIndex",
        ];
        for (const key in options) {
            if (!ts.contains(allKeys, key)) {
                ts.Debug.fail("Unexpected key " + key);
            }
        }
    }

    private validate(name: string, expected: string | undefined, actual: string | undefined) {
        if (expected && expected !== actual) {
            this.raiseError("Expected " + name + " '" + expected + "'.  Got '" + actual + "' instead.");
        }
    }

    public verifyRenameInfoSucceeded(
        displayName: string | undefined,
        fullDisplayName: string | undefined,
        kind: string | undefined,
        kindModifiers: string | undefined,
        fileToRename: string | undefined,
        expectedRange: Range | undefined,
        preferences: ts.UserPreferences | undefined,
    ): void {
        const renameInfo = this.languageService.getRenameInfo(this.activeFile.fileName, this.currentCaretPosition, preferences || { allowRenameOfImportPath: true });
        if (!renameInfo.canRename) {
            throw this.raiseError("Rename did not succeed");
        }

        this.validate("displayName", displayName, renameInfo.displayName);
        this.validate("fullDisplayName", fullDisplayName, renameInfo.fullDisplayName);
        this.validate("kind", kind, renameInfo.kind);
        this.validate("kindModifiers", kindModifiers, renameInfo.kindModifiers);
        this.validate("fileToRename", fileToRename, renameInfo.fileToRename);

        if (!expectedRange) {
            if (this.getRanges().length !== 1) {
                this.raiseError("Expected a single range to be selected in the test file.");
            }
            expectedRange = this.getRanges()[0];
        }

        if (
            renameInfo.triggerSpan.start !== expectedRange.pos ||
            ts.textSpanEnd(renameInfo.triggerSpan) !== expectedRange.end
        ) {
            this.raiseError(
                "Expected triggerSpan [" + expectedRange.pos + "," + expectedRange.end + ").  Got [" +
                    renameInfo.triggerSpan.start + "," + ts.textSpanEnd(renameInfo.triggerSpan) + ") instead.",
            );
        }
    }

    public verifyRenameInfoFailed(message?: string, preferences?: ts.UserPreferences): void {
        const allowRenameOfImportPath = preferences?.allowRenameOfImportPath === undefined ? true : preferences.allowRenameOfImportPath;
        const renameInfo = this.languageService.getRenameInfo(this.activeFile.fileName, this.currentCaretPosition, { ...preferences, allowRenameOfImportPath });
        if (renameInfo.canRename) {
            throw this.raiseError("Rename was expected to fail");
        }
        this.validate("error", message, renameInfo.localizedErrorMessage);
    }

    private alignmentForExtraInfo = 50;

    private spanLines(file: FourSlashFile, spanInfo: ts.TextSpan, { selection = false, fullLines = false, lineNumbers = false } = {}) {
        if (selection) {
            fullLines = true;
        }

        let contextStartPos = spanInfo.start;
        let contextEndPos = contextStartPos + spanInfo.length;
        if (fullLines) {
            if (contextStartPos > 0) {
                while (contextStartPos > 1) {
                    const ch = file.content.charCodeAt(contextStartPos - 1);
                    if (ch === ts.CharacterCodes.lineFeed || ch === ts.CharacterCodes.carriageReturn) {
                        break;
                    }
                    contextStartPos--;
                }
            }
            if (contextEndPos < file.content.length) {
                while (contextEndPos < file.content.length - 1) {
                    const ch = file.content.charCodeAt(contextEndPos);
                    if (ch === ts.CharacterCodes.lineFeed || ch === ts.CharacterCodes.carriageReturn) {
                        break;
                    }
                    contextEndPos++;
                }
            }
        }

        let contextString: string;
        let contextLineMap: number[];
        let contextStart: ts.LineAndCharacter;
        let contextEnd: ts.LineAndCharacter;
        let selectionStart: ts.LineAndCharacter;
        let selectionEnd: ts.LineAndCharacter;
        let lineNumberPrefixLength: number;
        if (lineNumbers) {
            contextString = file.content;
            contextLineMap = ts.computeLineStarts(contextString);
            contextStart = ts.computeLineAndCharacterOfPosition(contextLineMap, contextStartPos);
            contextEnd = ts.computeLineAndCharacterOfPosition(contextLineMap, contextEndPos);
            selectionStart = ts.computeLineAndCharacterOfPosition(contextLineMap, spanInfo.start);
            selectionEnd = ts.computeLineAndCharacterOfPosition(contextLineMap, ts.textSpanEnd(spanInfo));
            lineNumberPrefixLength = (contextEnd.line + 1).toString().length + 2;
        }
        else {
            contextString = file.content.substring(contextStartPos, contextEndPos);
            contextLineMap = ts.computeLineStarts(contextString);
            contextStart = { line: 0, character: 0 };
            contextEnd = { line: contextLineMap.length - 1, character: 0 };
            selectionStart = selection ? ts.computeLineAndCharacterOfPosition(contextLineMap, spanInfo.start - contextStartPos) : contextStart;
            selectionEnd = selection ? ts.computeLineAndCharacterOfPosition(contextLineMap, ts.textSpanEnd(spanInfo) - contextStartPos) : contextEnd;
            lineNumberPrefixLength = 0;
        }

        const output: string[] = [];
        for (let lineNumber = contextStart.line; lineNumber <= contextEnd.line; lineNumber++) {
            const spanLine = contextString.substring(contextLineMap[lineNumber], contextLineMap[lineNumber + 1]);
            output.push(lineNumbers ? `${`${lineNumber + 1}: `.padStart(lineNumberPrefixLength)}${spanLine}` : spanLine);
            if (selection) {
                if (lineNumber < selectionStart.line || lineNumber > selectionEnd.line) {
                    continue;
                }

                const isEmpty = selectionStart.line === selectionEnd.line && selectionStart.character === selectionEnd.character;
                const selectionPadLength = lineNumber === selectionStart.line ? selectionStart.character : 0;
                const selectionPad = " ".repeat(selectionPadLength + lineNumberPrefixLength);
                const selectionLength = isEmpty ? 0 : Math.max(lineNumber < selectionEnd.line ? spanLine.trimRight().length - selectionPadLength : selectionEnd.character - selectionPadLength, 1);
                const selectionLine = isEmpty ? "<" : "^".repeat(selectionLength);
                output.push(`${selectionPad}${selectionLine}`);
            }
        }
        return output;
    }

    private spanInfoToString(spanInfo: ts.TextSpan, prefixString: string, file: FourSlashFile = this.activeFile) {
        let resultString = "SpanInfo: " + JSON.stringify(spanInfo);
        if (spanInfo) {
            const spanLines = this.spanLines(file, spanInfo);
            for (let i = 0; i < spanLines.length; i++) {
                if (!i) {
                    resultString += "\n";
                }
                resultString += prefixString + spanLines[i];
            }
            resultString += "\n" + prefixString + ":=> (" + this.getLineColStringAtPosition(spanInfo.start, file) + ") to (" + this.getLineColStringAtPosition(ts.textSpanEnd(spanInfo), file) + ")";
        }

        return resultString;
    }

    private baselineCurrentFileLocations(getSpanAtPos: (pos: number) => ts.TextSpan): string {
        const fileLineMap = ts.computeLineStarts(this.activeFile.content);
        let nextLine = 0;
        let resultString = "";
        let currentLine: string;
        let previousSpanInfo: string | undefined;
        let startColumn: number | undefined;
        let length: number | undefined;
        const prefixString = "    >";

        let pos = 0;
        const addSpanInfoString = () => {
            if (previousSpanInfo) {
                resultString += currentLine;
                let thisLineMarker = ts.repeatString(" ", startColumn!) + ts.repeatString("~", length!);
                thisLineMarker += ts.repeatString(" ", this.alignmentForExtraInfo - thisLineMarker.length - prefixString.length + 1);
                resultString += thisLineMarker;
                resultString += "=> Pos: (" + (pos - length!) + " to " + (pos - 1) + ") ";
                resultString += " " + previousSpanInfo;
                previousSpanInfo = undefined;
            }
        };

        for (; pos < this.activeFile.content.length; pos++) {
            if (pos === 0 || pos === fileLineMap[nextLine]) {
                nextLine++;
                addSpanInfoString();
                if (resultString.length) {
                    resultString += "\n--------------------------------";
                }
                currentLine = "\n" + nextLine.toString() + ts.repeatString(" ", 3 - nextLine.toString().length) + ">" + this.activeFile.content.substring(pos, fileLineMap[nextLine]) + "\n    ";
                startColumn = 0;
                length = 0;
            }
            const spanInfo = this.spanInfoToString(getSpanAtPos(pos), prefixString);
            if (previousSpanInfo && previousSpanInfo !== spanInfo) {
                addSpanInfoString();
                previousSpanInfo = spanInfo;
                startColumn = startColumn! + length!;
                length = 1;
            }
            else {
                previousSpanInfo = spanInfo;
                length!++;
            }
        }
        addSpanInfoString();
        return resultString;
    }

    public getBreakpointStatementLocation(pos: number): ts.TextSpan | undefined {
        return this.languageService.getBreakpointStatementAtPosition(this.activeFile.fileName, pos);
    }

    public baselineCurrentFileBreakpointLocations(): void {
        this.baseline("breakpoints", this.baselineCurrentFileLocations(pos => this.getBreakpointStatementLocation(pos)!));
    }

    private getEmitFiles(): readonly FourSlashFile[] {
        // Find file to be emitted
        const emitFiles: FourSlashFile[] = []; // List of FourSlashFile that has emitThisFile flag on

        const allFourSlashFiles = this.testData.files;
        for (const file of allFourSlashFiles) {
            if (file.fileOptions[MetadataOptionNames.emitThisFile] === "true") {
                // Find a file with the flag emitThisFile turned on
                emitFiles.push(file);
            }
        }

        // If there is not emiThisFile flag specified in the test file, throw an error
        if (emitFiles.length === 0) {
            this.raiseError("No emitThisFile is specified in the test file");
        }

        return emitFiles;
    }

    public verifyGetEmitOutput(expectedOutputFiles: readonly string[]): void {
        const outputFiles = ts.flatMap(this.getEmitFiles(), e => this.languageService.getEmitOutput(e.fileName).outputFiles);

        assert.deepEqual(outputFiles.map(f => f.name), expectedOutputFiles);

        for (const { name, text } of outputFiles) {
            const fromTestFile = this.getFileContent(name);
            if (fromTestFile !== text) {
                this.raiseError(`Emit output for ${name} is not as expected: ${showTextDiff(fromTestFile, text)}`);
            }
        }
    }

    public baselineGetEmitOutput(): void {
        let resultString = "";
        // Loop through all the emittedFiles and emit them one by one
        for (const emitFile of this.getEmitFiles()) {
            const emitOutput = this.languageService.getEmitOutput(emitFile.fileName);
            // Print emitOutputStatus in readable format
            resultString += "EmitSkipped: " + emitOutput.emitSkipped + Harness.IO.newLine();

            if (emitOutput.emitSkipped) {
                resultString += "Diagnostics:" + Harness.IO.newLine();
                const diagnostics = ts.getPreEmitDiagnostics(this.languageService.getProgram()!); // TODO: GH#18217
                for (const diagnostic of diagnostics) {
                    if (!ts.isString(diagnostic.messageText)) {
                        resultString += this.flattenChainedMessage(diagnostic.messageText);
                    }
                    else {
                        resultString += "  " + diagnostic.messageText + Harness.IO.newLine();
                    }
                }
            }

            for (const outputFile of emitOutput.outputFiles) {
                const fileName = "FileName : " + outputFile.name + Harness.IO.newLine();
                resultString = resultString + Harness.IO.newLine() + fileName + outputFile.text;
            }
            resultString += Harness.IO.newLine();
        }

        this.baseline("EmitOutput", resultString);
    }

    private flattenChainedMessage(diag: ts.DiagnosticMessageChain, indent = " ") {
        let result = "";
        result += indent + diag.messageText + Harness.IO.newLine();
        if (diag.next) {
            for (const kid of diag.next) {
                result += this.flattenChainedMessage(kid, indent + " ");
            }
        }
        return result;
    }

    public baselineSyntacticDiagnostics(): void {
        const files = this.getCompilerTestFiles();
        const result = this.getSyntacticDiagnosticBaselineText(files);
        this.baseline("Syntax Diagnostics", result);
    }

    private getCompilerTestFiles() {
        return ts.map(this.testData.files, ({ content, fileName }) => ({
            content,
            unitName: fileName,
        }));
    }

    public baselineSyntacticAndSemanticDiagnostics(): void {
        const files = ts.filter(this.getCompilerTestFiles(), f => !ts.endsWith(f.unitName, ".json"));
        const result = this.getSyntacticDiagnosticBaselineText(files)
            + Harness.IO.newLine()
            + Harness.IO.newLine()
            + this.getSemanticDiagnosticBaselineText(files);
        this.baseline("Syntax and Semantic Diagnostics", result);
    }

    private getSyntacticDiagnosticBaselineText(files: Harness.Compiler.TestFile[]) {
        const diagnostics = ts.flatMap(files, file => this.languageService.getSyntacticDiagnostics(file.unitName));
        const result = `Syntactic Diagnostics for file '${this.originalInputFileName}':`
            + Harness.IO.newLine()
            + Harness.Compiler.getErrorBaseline(files, diagnostics, /*pretty*/ false);
        return result;
    }

    private getSemanticDiagnosticBaselineText(files: Harness.Compiler.TestFile[]) {
        const diagnostics = ts.flatMap(files, file => this.languageService.getSemanticDiagnostics(file.unitName));
        const result = `Semantic Diagnostics for file '${this.originalInputFileName}':`
            + Harness.IO.newLine()
            + Harness.Compiler.getErrorBaseline(files, diagnostics, /*pretty*/ false);
        return result;
    }

    public baselineQuickInfo(verbosityLevels?: VerbosityLevels, maximumLength?: number): void {
        const result = ts.arrayFrom(this.testData.markerPositions.entries(), ([name, marker]) => {
            const verbosityLevel = toArray(verbosityLevels?.[name]);
            const items = verbosityLevel.map(verbosityLevel => {
                const item: ts.QuickInfo & { verbosityLevel?: number; } | undefined = this.languageService.getQuickInfoAtPosition(
                    marker.fileName,
                    marker.position,
                    maximumLength,
                    verbosityLevel,
                );
                if (item) item.verbosityLevel = verbosityLevel;
                return {
                    marker: { ...marker, name },
                    item,
                };
            });
            return items;
        }).flat();
        const annotations = this.annotateContentWithTooltips(
            result,
            "quickinfo",
            item => item.textSpan,
            ({ displayParts, documentation, tags, verbosityLevel }) => [
                ...(displayParts ? displayParts.map(p => p.text).join("").split("\n") : []),
                ...(documentation?.length ? documentation.map(p => p.text).join("").split("\n") : []),
                ...(tags?.length ? tags.map(p => `@${p.name} ${p.text?.map(dp => dp.text).join("") ?? ""}`).join("\n").split("\n") : []),
                ...(verbosityLevel !== undefined ? [`(verbosity level: ${verbosityLevel})`] : []),
            ],
        );
        this.baseline("QuickInfo", annotations + "\n\n" + stringify(result));
    }

    public baselineSignatureHelp(): void {
        const result = ts.arrayFrom(this.testData.markerPositions.entries(), ([name, marker]) => ({
            marker: { ...marker, name },
            item: this.languageService.getSignatureHelpItems(marker.fileName, marker.position, /*options*/ undefined),
        }));
        const annotations = this.annotateContentWithTooltips(
            result,
            "signature help",
            () => undefined, // use default: marker.position
            (item, previous) => {
                const { documentation, tags, prefixDisplayParts, suffixDisplayParts, separatorDisplayParts, parameters } = item.items[item.selectedItemIndex];
                const tooltip = [];
                let signature = "";
                if (prefixDisplayParts.length) signature += prefixDisplayParts.map(p => p.text).join("");
                const separator = separatorDisplayParts.map(p => p.text).join("");
                signature += parameters.map((p, i) => {
                    const text = p.displayParts.map(dp => dp.text).join("");
                    return i === item.argumentIndex ? "**" + text + "**" : text;
                }).join(separator);
                if (suffixDisplayParts.length) signature += suffixDisplayParts.map(p => p.text).join("");
                tooltip.push(signature);
                // only display signature documentation on the last argument when multiple arguments are marked
                if (previous?.applicableSpan.start !== item.applicableSpan.start) {
                    if (documentation?.length) tooltip.push(...documentation.map(p => p.text).join("").split("\n"));
                    if (tags?.length) {
                        tooltip.push(...tags.map(p => `@${p.name} ${p.text?.map(dp => dp.text).join("") ?? ""}`).join("\n").split("\n"));
                    }
                }
                return tooltip;
            },
        );
        this.baseline("SignatureHelp", annotations + "\n\n" + stringify(result));
    }

    public baselineCompletions(preferences?: ts.UserPreferences): void {
        const result = ts.arrayFrom(this.testData.markerPositions.entries(), ([name, marker]) => {
            this.goToMarker(marker);
            const completions = this.getCompletionListAtCaret(preferences);
            return {
                marker: { ...marker, name },
                item: {
                    ...completions,
                    entries: completions?.entries.map(entry => ({
                        ...entry,
                        ...this.getCompletionEntryDetails(entry.name, entry.source, entry.data, preferences),
                    })),
                },
            };
        });
        const annotations = this.annotateContentWithTooltips(
            result,
            "completions",
            item => {
                if (item.optionalReplacementSpan) {
                    const { start, length } = item.optionalReplacementSpan;
                    return start && length === 0 ? { start, length: 1 } : item.optionalReplacementSpan;
                }
                return undefined;
            },
            item =>
                item.entries?.flatMap(
                    entry =>
                        entry.displayParts
                            ? entry.displayParts.map(p => p.text).join("").split("\n")
                            : [`(${entry.kindModifiers}${entry.kind}) ${entry.name}`],
                ),
        );
        for (const r of result) {
            for (const entry of r.item.entries ?? ts.emptyArray) {
                for (const tag of entry.tags ?? ts.emptyArray) {
                    for (const part of tag.text ?? ts.emptyArray) {
                        if (part.kind === "linkName") {
                            const link = part as ts.JSDocLinkDisplayPart;
                            if (/lib.*\.d\.ts$/.test(link.target.fileName)) {
                                // The object literal isn't a complete TextSpan, but we're only going to
                                // use these results in the baseline for diffing, so just overwrite.
                                (link.target.textSpan as any) = { start: "--", length: "--" };
                            }
                        }
                    }
                }
            }
        }
        this.baseline(
            "Completions",
            annotations + "\n\n" + stringify(result, (key, value) => {
                return key === "exportMapKey"
                    ? value.replace(/ \d+ /g, " * ")
                    : value;
            }),
        );
    }

    private annotateContentWithTooltips<
        T extends ts.QuickInfo | ts.SignatureHelpItems | {
            optionalReplacementSpan?: ts.TextSpan;
            entries?: {
                name: string;
                kind: string;
                kindModifiers?: string;
                displayParts?: unknown;
            }[];
        },
    >(
        items: ({
            marker: Marker & { name: string; };
            item: T | undefined;
        })[],
        opName: "completions" | "quickinfo" | "signature help",
        getSpan: (t: T) => ts.TextSpan | undefined,
        getToolTipContents: (t: T, prev: T | undefined) => string[] | undefined,
    ): string {
        const bar = "-".repeat(70);
        const sorted = items.slice();
        // sort by file, then *backwards* by position in the file so I can insert multiple times on a line without counting
        sorted.sort((q1, q2) =>
            q1.marker.fileName === q2.marker.fileName
                ? (q1.marker.position > q2.marker.position ? -1 : 1)
                : (q1.marker.fileName > q2.marker.fileName ? 1 : -1)
        );
        const files = new Map<string, string[]>();
        let previous: T | undefined;
        for (const { marker, item } of sorted) {
            const span = (item ? getSpan(item) : undefined) ?? { start: marker.position, length: 1 };
            const startLc = this.languageServiceAdapterHost.positionToLineAndCharacter(marker.fileName, span.start);
            const underline = " ".repeat(startLc.character) + "^".repeat(span.length);
            let tooltip = [
                bar,
                ...(item ? getToolTipContents(item, previous) : undefined) ?? [`No ${opName} at /*${marker.name}*/.`],
                bar,
            ];
            tooltip = tooltip.map(l => "| " + l);
            const lines = files.get(marker.fileName) ?? this.getFileContent(marker.fileName).split(/\r?\n/);
            lines.splice(startLc.line + 1, 0, underline, ...tooltip);
            files.set(marker.fileName, lines);
            previous = item;
        }
        return Array.from(files.entries(), ([fileName, lines]) => `=== ${fileName} ===\n` + lines.map(l => "// " + l).join("\n"))
            .join("\n\n");
    }

    public baselineSmartSelection(): void {
        const n = "\n";
        const markers = this.getMarkers();
        const fileContent = this.activeFile.content;
        const text = markers.map(marker => {
            const baselineContent = [fileContent.slice(0, marker.position) + "/**/" + fileContent.slice(marker.position) + n];
            let selectionRange: ts.SelectionRange | undefined = this.languageService.getSmartSelectionRange(this.activeFile.fileName, marker.position);
            while (selectionRange) {
                const { textSpan } = selectionRange;
                let masked = ts.arrayFrom(fileContent).map((char, index) => {
                    const charCode = char.charCodeAt(0);
                    if (index >= textSpan.start && index < ts.textSpanEnd(textSpan)) {
                        return char === " " ? "•" : ts.isLineBreak(charCode) ? `↲${n}` : char;
                    }
                    return ts.isLineBreak(charCode) ? char : " ";
                }).join("");
                masked = masked.replace(/^\s*$\r?\n?/gm, ""); // Remove blank lines
                const isRealCharacter = (char: string) => char !== "•" && char !== "↲" && !ts.isWhiteSpaceLike(char.charCodeAt(0));
                const leadingWidth = ts.arrayFrom(masked).findIndex(isRealCharacter);
                const trailingWidth = ts.findLastIndex(ts.arrayFrom(masked), isRealCharacter);
                masked = masked.slice(0, leadingWidth)
                    + masked.slice(leadingWidth, trailingWidth).replace(/•/g, " ").replace(/↲/g, "")
                    + masked.slice(trailingWidth);
                baselineContent.push(masked);
                selectionRange = selectionRange.parent;
            }
            return baselineContent.join(fileContent.includes("\n") ? n + n : n);
        }).join(n.repeat(2) + "=".repeat(80) + n.repeat(2));

        this.baseline("Smart Selection", text);
    }

    public printBreakpointLocation(pos: number): void {
        Harness.IO.log("\n**Pos: " + pos + " " + this.spanInfoToString(this.getBreakpointStatementLocation(pos)!, "  "));
    }

    public printBreakpointAtCurrentLocation(): void {
        this.printBreakpointLocation(this.currentCaretPosition);
    }

    public printCurrentParameterHelp(): void {
        const help = this.languageService.getSignatureHelpItems(this.activeFile.fileName, this.currentCaretPosition, /*options*/ undefined);
        Harness.IO.log(stringify(help));
    }

    public printCurrentQuickInfo(): void {
        const quickInfo = this.languageService.getQuickInfoAtPosition(this.activeFile.fileName, this.currentCaretPosition)!;
        Harness.IO.log("Quick Info: " + quickInfo.displayParts!.map(part => part.text).join(""));
    }

    public printErrorList(): void {
        const syntacticErrors = this.languageService.getSyntacticDiagnostics(this.activeFile.fileName);
        const semanticErrors = this.languageService.getSemanticDiagnostics(this.activeFile.fileName);
        const errorList = ts.concatenate(syntacticErrors, semanticErrors);
        Harness.IO.log(`Error list (${errorList.length} errors)`);

        if (errorList.length) {
            errorList.forEach(err => {
                Harness.IO.log(
                    "start: " + err.start +
                        ", length: " + err.length +
                        ", message: " + ts.flattenDiagnosticMessageText(err.messageText, Harness.IO.newLine()),
                );
            });
        }
    }

    public printCurrentFileState(showWhitespace: boolean, makeCaretVisible: boolean): void {
        for (const file of this.testData.files) {
            const active = this.activeFile === file;
            Harness.IO.log(`=== Script (${file.fileName}) ${(active ? "(active, cursor at |)" : "")} ===`);
            let content = this.getFileContent(file.fileName);
            if (active) {
                content = content.substr(0, this.currentCaretPosition) + (makeCaretVisible ? "|" : "") + content.substr(this.currentCaretPosition);
            }
            if (showWhitespace) {
                content = makeWhitespaceVisible(content);
            }
            Harness.IO.log(content);
        }
    }

    public printCurrentSignatureHelp(): void {
        const help = this.getSignatureHelp(ts.emptyOptions)!;
        Harness.IO.log(stringify(help.items[help.selectedItemIndex]));
    }

    private getBaselineFileNameForContainingTestFile(ext = ".baseline") {
        return this.testData.globalOptions[MetadataOptionNames.baselineFile] ||
            ts.getBaseFileName(this.originalInputFileName).replace(ts.Extension.Ts, ext);
    }

    private getSignatureHelp({ triggerReason }: FourSlashInterface.VerifySignatureHelpOptions): ts.SignatureHelpItems | undefined {
        return this.languageService.getSignatureHelpItems(this.activeFile.fileName, this.currentCaretPosition, {
            triggerReason,
        });
    }

    public printCompletionListMembers(preferences: ts.UserPreferences | undefined): void {
        const completions = this.getCompletionListAtCaret(preferences);
        this.printMembersOrCompletions(completions);
    }

    private printMembersOrCompletions(info: ts.CompletionInfo | undefined) {
        if (info === undefined) return "No completion info.";
        const { entries } = info;

        const longestNameLength = ts.maxBy(entries, 0, m => m.name.length);
        const longestKindLength = ts.maxBy(entries, 0, m => m.kind.length);
        entries.sort((m, n) => m.sortText > n.sortText ? 1 : m.sortText < n.sortText ? -1 : m.name > n.name ? 1 : m.name < n.name ? -1 : 0);

        const formattedEntries = entries.map(m => `${m.name.padEnd(longestNameLength)} ${m.kind.padEnd(longestKindLength)} ${m.kindModifiers} ${m.isRecommended ? "recommended " : ""}${m.source ?? ""}`);
        Harness.IO.log(formattedEntries.join("\n"));
    }

    public printContext(): void {
        ts.forEach(this.languageServiceAdapterHost.getFilenames(), Harness.IO.log);
    }

    public deleteChar(count = 1): void {
        let offset = this.currentCaretPosition;
        const ch = "";

        const checkCadence = (count >> 2) + 1;

        for (let i = 0; i < count; i++) {
            this.editScriptAndUpdateMarkers(this.activeFile.fileName, offset, offset + 1, ch);

            if (i % checkCadence === 0) {
                this.checkPostEditInvariants();
            }

            // Handle post-keystroke formatting
            if (this.enableFormatting) {
                const edits = this.languageService.getFormattingEditsAfterKeystroke(this.activeFile.fileName, offset, ch, this.formatCodeSettings);
                if (edits.length) {
                    offset += this.applyEdits(this.activeFile.fileName, edits);
                }
            }
        }

        this.checkPostEditInvariants();
    }

    public replace(start: number, length: number, text: string): void {
        this.editScriptAndUpdateMarkers(this.activeFile.fileName, start, start + length, text);
        this.checkPostEditInvariants();
    }

    public deleteLineRange(startIndex: number, endIndexInclusive: number): void {
        const startPos = this.languageServiceAdapterHost.lineAndCharacterToPosition(this.activeFile.fileName, { line: startIndex, character: 0 });
        const endPos = this.languageServiceAdapterHost.lineAndCharacterToPosition(this.activeFile.fileName, { line: endIndexInclusive + 1, character: 0 });
        this.replace(startPos, endPos - startPos, "");
    }

    public caretPosition(): Marker {
        return { fileName: this.activeFile.fileName, position: this.currentCaretPosition };
    }

    public deleteCharBehindMarker(count = 1): void {
        let offset = this.currentCaretPosition;
        const ch = "";
        const checkCadence = (count >> 2) + 1;

        for (let i = 0; i < count; i++) {
            this.currentCaretPosition--;
            offset--;
            this.editScriptAndUpdateMarkers(this.activeFile.fileName, offset, offset + 1, ch);

            if (i % checkCadence === 0) {
                this.checkPostEditInvariants();
            }

            // Don't need to examine formatting because there are no formatting changes on backspace.
        }

        this.checkPostEditInvariants();
    }

    // Enters lines of text at the current caret position
    public type(text: string, highFidelity = false): void {
        let offset = this.currentCaretPosition;
        const prevChar = " ";
        const checkCadence = (text.length >> 2) + 1;
        const selection = this.getSelection();
        this.replace(selection.pos, selection.end - selection.pos, "");

        for (let i = 0; i < text.length; i++) {
            const ch = text.charAt(i);
            this.editScriptAndUpdateMarkers(this.activeFile.fileName, offset, offset, ch);
            if (highFidelity) {
                this.languageService.getBraceMatchingAtPosition(this.activeFile.fileName, offset);
            }

            this.currentCaretPosition++;
            offset++;

            if (highFidelity) {
                if (ch === "(" || ch === "," || ch === "<") {
                    /* Signature help*/
                    this.languageService.getSignatureHelpItems(this.activeFile.fileName, offset, {
                        triggerReason: {
                            kind: "characterTyped",
                            triggerCharacter: ch,
                        },
                    });
                }
                else if (prevChar === " " && /A-Za-z_/.test(ch)) {
                    /* Completions */
                    this.languageService.getCompletionsAtPosition(this.activeFile.fileName, offset, ts.emptyOptions);
                }

                if (i % checkCadence === 0) {
                    this.checkPostEditInvariants();
                }
            }

            // Handle post-keystroke formatting
            if (this.enableFormatting) {
                const edits = this.languageService.getFormattingEditsAfterKeystroke(this.activeFile.fileName, offset, ch, this.formatCodeSettings);
                if (edits.length) {
                    offset += this.applyEdits(this.activeFile.fileName, edits);
                }
            }
        }

        this.checkPostEditInvariants();
    }

    // Enters text as if the user had pasted it
    public paste(text: string): void {
        const start = this.currentCaretPosition;
        this.editScriptAndUpdateMarkers(this.activeFile.fileName, this.currentCaretPosition, this.currentCaretPosition, text);
        this.checkPostEditInvariants();
        const offset = this.currentCaretPosition += text.length;

        // Handle formatting
        if (this.enableFormatting) {
            const edits = this.languageService.getFormattingEditsForRange(this.activeFile.fileName, start, offset, this.formatCodeSettings);
            if (edits.length) {
                this.applyEdits(this.activeFile.fileName, edits);
            }
        }

        this.checkPostEditInvariants();
    }

    private checkPostEditInvariants() {
        if (this.testType !== FourSlashTestType.Native) {
            // getSourcefile() results can not be serialized. Only perform these verifications
            // if running against a native LS object.
            return;
        }

        const incrementalSourceFile = this.languageService.getNonBoundSourceFile(this.activeFile.fileName);
        Utils.assertInvariants(incrementalSourceFile, /*parent:*/ undefined);

        const incrementalSyntaxDiagnostics = incrementalSourceFile.parseDiagnostics;

        // Check syntactic structure
        const content = this.getFileContent(this.activeFile.fileName);

        const options: ts.CreateSourceFileOptions = {
            languageVersion: ts.ScriptTarget.Latest,
            impliedNodeFormat: ts.getImpliedNodeFormatForFile(
                ts.toPath(this.activeFile.fileName, this.languageServiceAdapterHost.sys.getCurrentDirectory(), ts.hostGetCanonicalFileName(this.languageServiceAdapterHost)),
                /*packageJsonInfoCache*/ undefined,
                this.languageServiceAdapterHost,
                this.languageService.getProgram()?.getCompilerOptions() || {},
            ),
            setExternalModuleIndicator: ts.getSetExternalModuleIndicator(this.languageService.getProgram()?.getCompilerOptions() || {}),
            jsDocParsingMode: this.languageServiceAdapterHost.jsDocParsingMode,
        };
        const referenceSourceFile = ts.createLanguageServiceSourceFile(
            this.activeFile.fileName,
            createScriptSnapShot(content),
            options,
            /*version:*/ "0",
            /*setNodeParents*/ false,
        );
        const referenceSyntaxDiagnostics = referenceSourceFile.parseDiagnostics;

        Utils.assertDiagnosticsEquals(incrementalSyntaxDiagnostics, referenceSyntaxDiagnostics);
        Utils.assertStructuralEquals(incrementalSourceFile, referenceSourceFile);
    }

    /**
     * @returns The number of characters added to the file as a result of the edits.
     * May be negative.
     */
    private applyEdits(fileName: string, edits: readonly ts.TextChange[]): number {
        let runningOffset = 0;

        forEachTextChange(edits, edit => {
            const offsetStart = edit.span.start;
            const offsetEnd = offsetStart + edit.span.length;
            this.editScriptAndUpdateMarkers(fileName, offsetStart, offsetEnd, edit.newText);
            const editDelta = edit.newText.length - edit.span.length;
            if (offsetStart <= this.currentCaretPosition) {
                if (offsetEnd <= this.currentCaretPosition) {
                    // The entirety of the edit span falls before the caret position, shift the caret accordingly
                    this.currentCaretPosition += editDelta;
                }
                else {
                    // The span being replaced includes the caret position, place the caret at the beginning of the span
                    this.currentCaretPosition = offsetStart;
                }
            }
            runningOffset += editDelta;
        });

        return runningOffset;
    }

    public copyFormatOptions(): ts.FormatCodeSettings {
        return ts.clone(this.formatCodeSettings);
    }

    public setFormatOptions(formatCodeOptions: ts.FormatCodeOptions | ts.FormatCodeSettings): ts.FormatCodeSettings {
        const oldFormatCodeOptions = this.formatCodeSettings;
        this.formatCodeSettings = ts.toEditorSettings(formatCodeOptions);
        if (this.testType === FourSlashTestType.Server) {
            (this.languageService as ts.server.SessionClient).setFormattingOptions(this.formatCodeSettings);
        }
        return oldFormatCodeOptions;
    }

    public formatDocument(): void {
        const edits = this.languageService.getFormattingEditsForDocument(this.activeFile.fileName, this.formatCodeSettings);
        this.applyEdits(this.activeFile.fileName, edits);
    }

    public formatSelection(start: number, end: number): void {
        const edits = this.languageService.getFormattingEditsForRange(this.activeFile.fileName, start, end, this.formatCodeSettings);
        this.applyEdits(this.activeFile.fileName, edits);
    }

    public formatOnType(pos: number, key: string): void {
        const edits = this.languageService.getFormattingEditsAfterKeystroke(this.activeFile.fileName, pos, key, this.formatCodeSettings);
        this.applyEdits(this.activeFile.fileName, edits);
    }

    private editScriptAndUpdateMarkers(fileName: string, editStart: number, editEnd: number, newText: string) {
        this.languageServiceAdapterHost.editScript(fileName, editStart, editEnd, newText);
        if (this.assertTextConsistent) {
            this.assertTextConsistent(fileName);
        }
        for (const marker of this.testData.markers) {
            if (marker.fileName === fileName) {
                marker.position = updatePosition(marker.position, editStart, editEnd, newText);
            }
        }

        for (const range of this.testData.ranges) {
            if (range.fileName === fileName) {
                range.pos = updatePosition(range.pos, editStart, editEnd, newText);
                range.end = updatePosition(range.end, editStart, editEnd, newText);
            }
        }
        this.testData.rangesByText = undefined;
    }

    private removeWhitespace(text: string): string {
        return text.replace(/\s/g, "");
    }

    public goToBOF(): void {
        this.goToPosition(0);
    }

    public goToEOF(): void {
        const len = this.getFileContent(this.activeFile.fileName).length;
        this.goToPosition(len);
    }

    public goToRangeStart({ fileName, pos }: Range): void {
        this.openFile(fileName);
        this.goToPosition(pos);
    }

    public getMarkers(): Marker[] {
        //  Return a copy of the list
        return this.testData.markers.slice(0);
    }

    public getMarkerNames(): string[] {
        return ts.arrayFrom(this.testData.markerPositions.keys());
    }

    public getRanges(): Range[] {
        return this.testData.ranges;
    }

    public getRangesInFile(fileName: string = this.activeFile.fileName): Range[] {
        return this.getRanges().filter(r => r.fileName === fileName);
    }

    public rangesByText(): Map<string, Range[]> {
        if (this.testData.rangesByText) return this.testData.rangesByText;
        const result = ts.createMultiMap<string, Range>();
        this.testData.rangesByText = result;
        for (const range of this.getRanges()) {
            const text = this.rangeText(range);
            result.add(text, range);
        }
        return result;
    }

    private rangeText({ fileName, pos, end }: Range): string {
        return this.getFileContent(fileName).slice(pos, end);
    }

    public verifyCaretAtMarker(markerName = ""): void {
        const pos = this.getMarkerByName(markerName);
        if (pos.fileName !== this.activeFile.fileName) {
            throw new Error(`verifyCaretAtMarker failed - expected to be in file "${pos.fileName}", but was in file "${this.activeFile.fileName}"`);
        }
        if (pos.position !== this.currentCaretPosition) {
            throw new Error(`verifyCaretAtMarker failed - expected to be at marker "/*${markerName}*/, but was at position ${this.currentCaretPosition}(${this.getLineColStringAtPosition(this.currentCaretPosition)})`);
        }
    }

    private getIndentation(fileName: string, position: number, indentStyle: ts.IndentStyle, baseIndentSize: number): number {
        const formatOptions = ts.clone(this.formatCodeSettings);
        formatOptions.indentStyle = indentStyle;
        formatOptions.baseIndentSize = baseIndentSize;
        return this.languageService.getIndentationAtPosition(fileName, position, formatOptions);
    }

    public verifyIndentationAtCurrentPosition(numberOfSpaces: number, indentStyle: ts.IndentStyle = ts.IndentStyle.Smart, baseIndentSize = 0): void {
        const actual = this.getIndentation(this.activeFile.fileName, this.currentCaretPosition, indentStyle, baseIndentSize);
        const lineCol = this.getLineColStringAtPosition(this.currentCaretPosition);
        if (actual !== numberOfSpaces) {
            this.raiseError(`verifyIndentationAtCurrentPosition failed at ${lineCol} - expected: ${numberOfSpaces}, actual: ${actual}`);
        }
    }

    public verifyIndentationAtPosition(fileName: string, position: number, numberOfSpaces: number, indentStyle: ts.IndentStyle = ts.IndentStyle.Smart, baseIndentSize = 0): void {
        const actual = this.getIndentation(fileName, position, indentStyle, baseIndentSize);
        const lineCol = this.getLineColStringAtPosition(position);
        if (actual !== numberOfSpaces) {
            this.raiseError(`verifyIndentationAtPosition failed at ${lineCol} - expected: ${numberOfSpaces}, actual: ${actual}`);
        }
    }

    public verifyCurrentLineContent(text: string): void {
        const actual = this.getCurrentLineContent();
        if (actual !== text) {
            throw new Error("verifyCurrentLineContent\n" + displayExpectedAndActualString(text, actual, /*quoted*/ true));
        }
    }

    public verifyCurrentFileContent(text: string): void {
        this.verifyFileContent(this.activeFile.fileName, text);
    }

    private verifyFileContent(fileName: string, text: string) {
        const actual = this.getFileContent(fileName);
        if (actual !== text) {
            throw new Error(`verifyFileContent in file '${fileName}' failed:\n${showTextDiff(text, actual)}`);
        }
    }

    public verifyFormatDocumentChangesNothing(): void {
        const { fileName } = this.activeFile;
        const before = this.getFileContent(fileName);
        this.formatDocument();
        this.verifyFileContent(fileName, before);
    }

    public verifyTextAtCaretIs(text: string): void {
        const actual = this.getFileContent(this.activeFile.fileName).substring(this.currentCaretPosition, this.currentCaretPosition + text.length);
        if (actual !== text) {
            throw new Error("verifyTextAtCaretIs\n" + displayExpectedAndActualString(text, actual, /*quoted*/ true));
        }
    }

    public verifyCurrentNameOrDottedNameSpanText(text: string): undefined {
        const span = this.languageService.getNameOrDottedNameSpan(this.activeFile.fileName, this.currentCaretPosition, this.currentCaretPosition);
        if (!span) {
            return this.raiseError("verifyCurrentNameOrDottedNameSpanText\n" + displayExpectedAndActualString('"' + text + '"', "undefined"));
        }

        const actual = this.getFileContent(this.activeFile.fileName).substring(span.start, ts.textSpanEnd(span));
        if (actual !== text) {
            this.raiseError("verifyCurrentNameOrDottedNameSpanText\n" + displayExpectedAndActualString(text, actual, /*quoted*/ true));
        }
    }

    private getNameOrDottedNameSpan(pos: number) {
        return this.languageService.getNameOrDottedNameSpan(this.activeFile.fileName, pos, pos);
    }

    public baselineCurrentFileNameOrDottedNameSpans(): void {
        this.baseline(
            "NameOrDottedNameSpans",
            this.baselineCurrentFileLocations(pos => this.getNameOrDottedNameSpan(pos)!),
        );
    }

    public printNameOrDottedNameSpans(pos: number): void {
        Harness.IO.log(this.spanInfoToString(this.getNameOrDottedNameSpan(pos)!, "**"));
    }

    private classificationToIdentifier(classification: number) {
        const tokenTypes: string[] = [];
        tokenTypes[ts.classifier.v2020.TokenType.class] = "class";
        tokenTypes[ts.classifier.v2020.TokenType.enum] = "enum";
        tokenTypes[ts.classifier.v2020.TokenType.interface] = "interface";
        tokenTypes[ts.classifier.v2020.TokenType.namespace] = "namespace";
        tokenTypes[ts.classifier.v2020.TokenType.typeParameter] = "typeParameter";
        tokenTypes[ts.classifier.v2020.TokenType.type] = "type";
        tokenTypes[ts.classifier.v2020.TokenType.parameter] = "parameter";
        tokenTypes[ts.classifier.v2020.TokenType.variable] = "variable";
        tokenTypes[ts.classifier.v2020.TokenType.enumMember] = "enumMember";
        tokenTypes[ts.classifier.v2020.TokenType.property] = "property";
        tokenTypes[ts.classifier.v2020.TokenType.function] = "function";
        tokenTypes[ts.classifier.v2020.TokenType.member] = "member";

        const tokenModifiers: string[] = [];
        tokenModifiers[ts.classifier.v2020.TokenModifier.async] = "async";
        tokenModifiers[ts.classifier.v2020.TokenModifier.declaration] = "declaration";
        tokenModifiers[ts.classifier.v2020.TokenModifier.readonly] = "readonly";
        tokenModifiers[ts.classifier.v2020.TokenModifier.static] = "static";
        tokenModifiers[ts.classifier.v2020.TokenModifier.local] = "local";
        tokenModifiers[ts.classifier.v2020.TokenModifier.defaultLibrary] = "defaultLibrary";

        function getTokenTypeFromClassification(tsClassification: number): number | undefined {
            if (tsClassification > ts.classifier.v2020.TokenEncodingConsts.modifierMask) {
                return (tsClassification >> ts.classifier.v2020.TokenEncodingConsts.typeOffset) - 1;
            }
            return undefined;
        }

        function getTokenModifierFromClassification(tsClassification: number) {
            return tsClassification & ts.classifier.v2020.TokenEncodingConsts.modifierMask;
        }

        const typeIdx = getTokenTypeFromClassification(classification) || 0;
        const modSet = getTokenModifierFromClassification(classification);

        return [tokenTypes[typeIdx], ...tokenModifiers.filter((_, i) => modSet & 1 << i)].join(".");
    }

    private verifyClassifications(expected: { classificationType: string | number; text?: string; textSpan?: TextSpan; }[], actual: (ts.ClassifiedSpan | ts.ClassifiedSpan2020)[], sourceFileText: string) {
        if (actual.length !== expected.length) {
            this.raiseError(
                "verifyClassifications failed - expected total classifications to be " + expected.length +
                    ", but was " + actual.length +
                    jsonMismatchString(),
            );
        }

        ts.zipWith(expected, actual, (expectedClassification, actualClassification) => {
            const expectedType = expectedClassification.classificationType;
            const actualType = typeof actualClassification.classificationType === "number" ? this.classificationToIdentifier(actualClassification.classificationType) : actualClassification.classificationType;

            if (expectedType !== actualType) {
                this.raiseError(
                    "verifyClassifications failed - expected classifications type to be " +
                        expectedType + ", but was " +
                        actualType +
                        jsonMismatchString(),
                );
            }

            const expectedSpan = expectedClassification.textSpan;
            const actualSpan = actualClassification.textSpan;

            if (expectedSpan) {
                const expectedLength = expectedSpan.end - expectedSpan.start;

                if (expectedSpan.start !== actualSpan.start || expectedLength !== actualSpan.length) {
                    this.raiseError(
                        "verifyClassifications failed - expected span of text to be " +
                            "{start=" + expectedSpan.start + ", length=" + expectedLength + "}, but was " +
                            "{start=" + actualSpan.start + ", length=" + actualSpan.length + "}" +
                            jsonMismatchString(),
                    );
                }
            }

            const actualText = this.activeFile.content.substr(actualSpan.start, actualSpan.length);
            if (expectedClassification.text !== actualText) {
                this.raiseError(
                    "verifyClassifications failed - expected classified text to be " +
                        expectedClassification.text + ", but was " +
                        actualText +
                        jsonMismatchString(),
                );
            }
        });

        function jsonMismatchString() {
            const showActual = actual.map(({ classificationType, textSpan }) => ({ classificationType, text: sourceFileText.slice(textSpan.start, textSpan.start + textSpan.length) }));
            return Harness.IO.newLine() +
                "expected: '" + Harness.IO.newLine() + stringify(expected) + "'" + Harness.IO.newLine() +
                "actual:   '" + Harness.IO.newLine() + stringify(showActual) + "'";
        }
    }

    public verifyProjectInfo(expected: string[]): void {
        if (this.testType === FourSlashTestType.Server) {
            const actual = (this.languageService as ts.server.SessionClient).getProjectInfo(
                this.activeFile.fileName,
                /*needFileNameList*/ true,
            );
            assert.equal(
                expected.join(","),
                actual.fileNames!.map(file => {
                    return file.replace(this.basePath + "/", "");
                }).join(","),
            );
        }
    }

    public replaceWithSemanticClassifications(format: ts.SemanticClassificationFormat.TwentyTwenty): void {
        const actual = this.languageService.getSemanticClassifications(this.activeFile.fileName, ts.createTextSpan(0, this.activeFile.content.length), format);
        const replacement = [`const c2 = classification("2020");`, `verify.semanticClassificationsAre("2020",`];
        for (const a of actual) {
            const identifier = this.classificationToIdentifier(a.classificationType as number);
            const text = this.activeFile.content.slice(a.textSpan.start, a.textSpan.start + a.textSpan.length);
            replacement.push(`    c2.semanticToken("${identifier}", "${text}"), `);
        }
        replacement.push(");");

        throw new Error("You need to change the source code of fourslash test to use replaceWithSemanticClassifications");

        // const fs = require("fs");
        // const testfilePath = this.originalInputFileName.slice(1);
        // const testfile = fs.readFileSync(testfilePath, "utf8");
        // const newfile = testfile.replace("verify.replaceWithSemanticClassifications(\"2020\")", replacement.join("\n"));
        // fs.writeFileSync(testfilePath, newfile);
    }

    public verifyEncodedSyntacticClassificationsLength(expected: number): void {
        const actual = this.languageService.getEncodedSyntacticClassifications(this.activeFile.fileName, ts.createTextSpan(0, this.activeFile.content.length));
        if (actual.spans.length !== expected) {
            this.raiseError(`encodedSyntacticClassificationsLength failed - expected total spans to be ${expected} got ${actual.spans.length}`);
        }
    }

    public verifyEncodedSemanticClassificationsLength(format: ts.SemanticClassificationFormat, expected: number): void {
        const actual = this.languageService.getEncodedSemanticClassifications(this.activeFile.fileName, ts.createTextSpan(0, this.activeFile.content.length), format);
        if (actual.spans.length !== expected) {
            this.raiseError(`encodedSemanticClassificationsLength failed - expected total spans to be ${expected} got ${actual.spans.length}`);
        }
    }

    public verifySemanticClassifications(format: ts.SemanticClassificationFormat, expected: { classificationType: string | number; text?: string; }[]): void {
        const actual = this.languageService.getSemanticClassifications(this.activeFile.fileName, ts.createTextSpan(0, this.activeFile.content.length), format);
        this.verifyClassifications(expected, actual, this.activeFile.content);
    }

    public verifySyntacticClassifications(expected: { classificationType: string; text: string; }[]): void {
        const actual = this.languageService.getSyntacticClassifications(this.activeFile.fileName, ts.createTextSpan(0, this.activeFile.content.length));

        this.verifyClassifications(expected, actual, this.activeFile.content);
    }

    public printOutliningSpans(): void {
        const spans = this.languageService.getOutliningSpans(this.activeFile.fileName);
        Harness.IO.log(`Outlining spans (${spans.length} items)\nResults:`);
        Harness.IO.log(stringify(spans));
        this.printOutliningSpansInline(spans);
    }

    private printOutliningSpansInline(spans: ts.OutliningSpan[]) {
        const allSpanInsets = [] as { text: string; pos: number; }[];
        let annotated = this.activeFile.content;
        ts.forEach(spans, span => {
            allSpanInsets.push({ text: "[|", pos: span.textSpan.start });
            allSpanInsets.push({ text: "|]", pos: span.textSpan.start + span.textSpan.length });
        });

        allSpanInsets.sort((l, r) => r.pos - l.pos);
        ts.forEach(allSpanInsets, span => {
            annotated = annotated.slice(0, span.pos) + span.text + annotated.slice(span.pos);
        });
        Harness.IO.log(`\nMockup:\n${annotated}`);
    }

    public verifyOutliningSpans(spans: Range[], kind?: "comment" | "region" | "code" | "imports"): void {
        const actual = this.languageService.getOutliningSpans(this.activeFile.fileName);

        const filterActual = ts.filter(actual, f => kind === undefined ? true : f.kind === kind);
        if (filterActual.length !== spans.length) {
            this.raiseError(`verifyOutliningSpans failed - expected total spans to be ${spans.length}, but was ${actual.length}\n\nFound Spans:\n\n${this.printOutliningSpansInline(actual)}`);
        }

        ts.zipWith(spans, filterActual, (expectedSpan, actualSpan, i) => {
            if (expectedSpan.pos !== actualSpan.textSpan.start || expectedSpan.end !== ts.textSpanEnd(actualSpan.textSpan)) {
                return this.raiseError(`verifyOutliningSpans failed - span ${(i + 1)} expected: (${expectedSpan.pos},${expectedSpan.end}),  actual: (${actualSpan.textSpan.start},${ts.textSpanEnd(actualSpan.textSpan)})`);
            }
            if (kind !== undefined && actualSpan.kind !== kind) {
                return this.raiseError(`verifyOutliningSpans failed - span ${(i + 1)} expected kind: ('${kind}'),  actual: ('${actualSpan.kind}')`);
            }
        });
    }

    public verifyOutliningHintSpans(spans: Range[]): void {
        const actual = this.languageService.getOutliningSpans(this.activeFile.fileName);

        if (actual.length !== spans.length) {
            this.raiseError(`verifyOutliningHintSpans failed - expected total spans to be ${spans.length}, but was ${actual.length}`);
        }

        ts.zipWith(spans, actual, (expectedSpan, actualSpan, i) => {
            if (expectedSpan.pos !== actualSpan.hintSpan.start || expectedSpan.end !== ts.textSpanEnd(actualSpan.hintSpan)) {
                return this.raiseError(`verifyOutliningSpans failed - span ${(i + 1)} expected: (${expectedSpan.pos},${expectedSpan.end}),  actual: (${actualSpan.hintSpan.start},${ts.textSpanEnd(actualSpan.hintSpan)})`);
            }
        });
    }

    public verifyTodoComments(descriptors: string[], spans: Range[]): void {
        const actual = this.languageService.getTodoComments(this.activeFile.fileName, descriptors.map(d => ({ text: d, priority: 0 })));

        if (actual.length !== spans.length) {
            this.raiseError(`verifyTodoComments failed - expected total spans to be ${spans.length}, but was ${actual.length}`);
        }

        ts.zipWith(spans, actual, (expectedSpan, actualComment, i) => {
            const actualCommentSpan = ts.createTextSpan(actualComment.position, actualComment.message.length);

            if (expectedSpan.pos !== actualCommentSpan.start || expectedSpan.end !== ts.textSpanEnd(actualCommentSpan)) {
                this.raiseError(`verifyOutliningSpans failed - span ${(i + 1)} expected: (${expectedSpan.pos},${expectedSpan.end}),  actual: (${actualCommentSpan.start},${ts.textSpanEnd(actualCommentSpan)})`);
            }
        });
    }

    /**
     * Finds and applies a code action corresponding to the supplied parameters.
     * If index is undefined, applies the unique code action available.
     * @param errorCode The error code that generated the code action.
     * @param index The nth (0-index-based) codeaction available generated by errorCode.
     */
    public getAndApplyCodeActions(errorCode?: number, index?: number): void {
        const fileName = this.activeFile.fileName;
        const fixes = this.getCodeFixes(fileName, errorCode);
        if (index === undefined) {
            if (!(fixes && fixes.length === 1)) {
                this.raiseError(`Should find exactly one codefix, but ${fixes ? fixes.length : "none"} found. ${fixes ? fixes.map(a => `${Harness.IO.newLine()} "${a.description}"`) : ""}`);
            }
            index = 0;
        }
        else {
            if (!(fixes && fixes.length >= index + 1)) {
                this.raiseError(`Should find at least ${index + 1} codefix(es), but ${fixes ? fixes.length : "none"} found.`);
            }
        }

        this.applyChanges(fixes[index].changes);
    }

    public applyCodeActionFromCompletion(markerName: string | undefined, options: FourSlashInterface.VerifyCompletionActionOptions): undefined {
        if (markerName !== undefined) {
            this.goToMarker(markerName);
        }

        const details = this.getCompletionEntryDetails(options.name, options.source, options.data, options.preferences);
        if (!details) {
            const completions = this.getCompletionListAtCaret(options.preferences)?.entries;
            const matchingName = completions?.filter(e => e.name === options.name);
            const detailMessage = matchingName?.length
                ? `\n  Found ${matchingName.length} with name '${options.name}' from source(s) ${matchingName.map(e => `'${e.source}'`).join(", ")}.`
                : ` (In fact, there were no completions with name '${options.name}' at all.)`;
            return this.raiseError(`No completions were found for the given name, source/data, and preferences.` + detailMessage);
        }
        const codeActions = details.codeActions;
        if (codeActions?.length !== 1) {
            this.raiseError(`Expected one code action, got ${codeActions?.length ?? 0}`);
        }
        const codeAction = ts.first(codeActions);

        if (codeAction.description !== options.description) {
            this.raiseError(`Expected description to be:\n${options.description}\ngot:\n${codeActions[0].description}`);
        }
        this.applyChanges(codeAction.changes);

        this.verifyNewContentAfterChange(options, ts.flatMap(codeActions, a => a.changes.map(c => c.fileName)));
    }

    public verifyRangeIs(expectedText: string, includeWhiteSpace?: boolean): void {
        this.verifyTextMatches(this.rangeText(this.getOnlyRange()), !!includeWhiteSpace, expectedText);
    }

    private getOnlyRange(fileName?: string) {
        const ranges = fileName ? this.getRangesInFile(fileName) : this.getRanges();
        if (ranges.length !== 1) {
            this.raiseError("Exactly one range should be specified in the testfile.");
        }
        return ts.first(ranges);
    }

    private verifyTextMatches(actualText: string, includeWhitespace: boolean, expectedText: string, fileName?: string) {
        const removeWhitespace = (s: string): string => includeWhitespace ? s : this.removeWhitespace(s);
        if (removeWhitespace(actualText) !== removeWhitespace(expectedText)) {
            const addFileName = fileName ? ` in file '${fileName}'` : "";
            this.raiseError(`Actual range text${addFileName} doesn't match expected text.\n${showTextDiff(expectedText, actualText)}`);
        }
    }

    /**
     * Compares expected text to the text that would be in the sole range
     * (ie: [|...|]) in the file after applying the codefix sole codefix
     * in the source file.
     */
    public verifyRangeAfterCodeFix(expectedText: string, includeWhiteSpace?: boolean, errorCode?: number, index?: number): void {
        this.getAndApplyCodeActions(errorCode, index);
        this.verifyRangeIs(expectedText, includeWhiteSpace);
    }

    public verifyCodeFixAll({ fixId, fixAllDescription, newFileContent, commands: expectedCommands, preferences }: FourSlashInterface.VerifyCodeFixAllOptions): void {
        if (this.testType === FourSlashTestType.Server && preferences) {
            this.configure(preferences);
        }

        const fixWithId = ts.find(this.getCodeFixes(this.activeFile.fileName, /*errorCode*/ undefined, preferences), a => a.fixId === fixId);
        ts.Debug.assert(fixWithId !== undefined, "No available code fix has the expected id. Fix All is not available if there is only one potentially fixable diagnostic present.", () => `Expected '${fixId}'. Available actions:\n${ts.mapDefined(this.getCodeFixes(this.activeFile.fileName), a => `${a.fixName} (${a.fixId || "no fix id"})`).join("\n")}`);
        ts.Debug.assertEqual(fixWithId.fixAllDescription, fixAllDescription);

        const { changes, commands } = this.languageService.getCombinedCodeFix({ type: "file", fileName: this.activeFile.fileName }, fixId, this.formatCodeSettings, preferences || ts.emptyOptions);
        assert.deepEqual<readonly {}[] | undefined>(commands, expectedCommands);
        this.verifyNewContent({ newFileContent }, changes);
    }

    public verifyCodeFix(options: FourSlashInterface.VerifyCodeFixOptions): void {
        const fileName = this.activeFile.fileName;
        const actions = this.getCodeFixes(fileName, options.errorCode, options.preferences);
        let index = options.index;
        if (index === undefined) {
            if (!(actions && actions.length === 1)) {
                this.raiseError(`Should find exactly one codefix, but ${actions ? actions.length : "none"} found. ${actions ? actions.map(a => `${Harness.IO.newLine()} "${a.description}"`) : ""}`);
            }
            index = 0;
        }
        else {
            if (!(actions && actions.length >= index + 1)) {
                this.raiseError(`Should find at least ${index + 1} codefix(es), but ${actions ? actions.length : "none"} found.`);
            }
        }

        const action = actions[index];

        if (typeof options.description === "string") {
            assert.equal(action.description, options.description);
        }
        else if (Array.isArray(options.description)) {
            const description = ts.formatStringFromArgs(options.description[0], options.description.slice(1));
            assert.equal(action.description, description);
        }
        else {
            assert.match(action.description, templateToRegExp(options.description.template));
        }
        assert.deepEqual(action.commands, options.commands);

        if (options.applyChanges) {
            for (const change of action.changes) {
                this.applyEdits(change.fileName, change.textChanges);
            }
            this.verifyNewContentAfterChange(options, action.changes.map(c => c.fileName));
        }
        else {
            this.verifyNewContent(options, action.changes);
        }
    }

    private verifyNewContent({ newFileContent, newRangeContent }: FourSlashInterface.NewContentOptions, changes: readonly ts.FileTextChanges[]): void {
        if (newRangeContent !== undefined) {
            assert(newFileContent === undefined);
            assert(changes.length === 1, "Affected 0 or more than 1 file, must use 'newFileContent' instead of 'newRangeContent'");
            const change = ts.first(changes);
            assert(change.fileName = this.activeFile.fileName);
            const newText = ts.textChanges.applyChanges(this.getFileContent(this.activeFile.fileName), change.textChanges);
            const newRange = updateTextRangeForTextChanges(this.getOnlyRange(this.activeFile.fileName), change.textChanges);
            const actualText = newText.slice(newRange.pos, newRange.end);
            this.verifyTextMatches(actualText, /*includeWhitespace*/ true, newRangeContent, change.fileName);
        }
        else {
            if (newFileContent === undefined) throw ts.Debug.fail();
            if (typeof newFileContent !== "object") newFileContent = { [this.activeFile.fileName]: newFileContent };
            for (const change of changes) {
                const expectedNewContent = newFileContent[change.fileName];
                if (expectedNewContent === undefined) {
                    ts.Debug.fail(`Did not expect a change in ${change.fileName}`);
                }
                const oldText = this.tryGetFileContent(change.fileName);
                const newContent = change.isNewFile ? ts.first(change.textChanges).newText : ts.textChanges.applyChanges(oldText!, change.textChanges);
                this.verifyTextMatches(newContent, /*includeWhitespace*/ true, expectedNewContent, change.fileName);
            }
            for (const newFileName in newFileContent) {
                ts.Debug.assert(changes.some(c => c.fileName === newFileName), "No change in file", () => newFileName);
            }
        }
    }

    private verifyNewContentAfterChange({ newFileContent, newRangeContent }: FourSlashInterface.NewContentOptions, changedFiles: readonly string[]) {
        const assertedChangedFiles = !newFileContent || typeof newFileContent === "string"
            ? [this.activeFile.fileName]
            : ts.getOwnKeys(newFileContent);
        assert.deepEqual(assertedChangedFiles, changedFiles);

        if (newFileContent !== undefined) {
            assert(!newRangeContent);
            if (typeof newFileContent === "string") {
                this.verifyCurrentFileContent(newFileContent);
            }
            else {
                for (const fileName in newFileContent) {
                    this.verifyFileContent(fileName, newFileContent[fileName]);
                }
            }
        }
        else {
            this.verifyRangeIs(newRangeContent!, /*includeWhiteSpace*/ true);
        }
    }

    /**
     * Rerieves a codefix satisfying the parameters, or undefined if no such codefix is found.
     * @param fileName Path to file where error should be retrieved from.
     */
    private getCodeFixes(fileName: string, errorCode?: number, preferences: ts.UserPreferences = ts.emptyOptions, position?: number): readonly ts.CodeFixAction[] {
        if (this.testType === FourSlashTestType.Server) {
            this.configure(preferences);
        }

        const diagnosticsForCodeFix = this.getDiagnostics(fileName, /*includeSuggestions*/ true).map(diagnostic => ({
            start: diagnostic.start,
            length: diagnostic.length,
            code: diagnostic.code,
        }));

        return ts.flatMap(ts.deduplicate(diagnosticsForCodeFix, ts.equalOwnProperties), diagnostic => {
            if (errorCode !== undefined && errorCode !== diagnostic.code) {
                return;
            }
            if (position !== undefined && diagnostic.start !== undefined && diagnostic.length !== undefined) {
                const span = ts.createTextRangeFromSpan({ start: diagnostic.start, length: diagnostic.length });
                if (!ts.textRangeContainsPositionInclusive(span, position)) {
                    return;
                }
            }
            return this.languageService.getCodeFixesAtPosition(fileName, diagnostic.start!, diagnostic.start! + diagnostic.length!, [diagnostic.code], this.formatCodeSettings, preferences);
        });
    }

    private applyChanges(changes: readonly ts.FileTextChanges[]): void {
        for (const change of changes) {
            this.applyEdits(change.fileName, change.textChanges);
        }
    }

    public verifyImportFixAtPosition(expectedTextArray: string[], errorCode: number | undefined, preferences: ts.UserPreferences | undefined): void {
        const { fileName } = this.activeFile;
        const ranges = this.getRanges().filter(r => r.fileName === fileName);
        if (ranges.length > 1) {
            this.raiseError("Exactly one range should be specified in the testfile.");
        }
        const range = ts.firstOrUndefined(ranges);

        if (preferences) {
            this.configure(preferences);
        }

        const codeFixes = this.getCodeFixes(fileName, errorCode, preferences).filter(f => f.fixName === ts.codefix.importFixName);

        if (codeFixes.length === 0) {
            if (expectedTextArray.length !== 0) {
                this.raiseError("No codefixes returned.");
            }
            return;
        }

        const actualTextArray: string[] = [];
        const scriptInfo = this.languageServiceAdapterHost.getScriptInfo(fileName)!;
        const originalContent = scriptInfo.content;
        for (const codeFix of codeFixes) {
            ts.Debug.assert(codeFix.changes.length === 1);
            const change = ts.first(codeFix.changes);
            ts.Debug.assert(change.fileName === fileName);
            this.applyEdits(change.fileName, change.textChanges);
            const text = range ? this.rangeText(range) : this.getFileContent(fileName);
            actualTextArray.push(text);

            // Undo changes to perform next fix
            for (const textChange of change.textChanges) {
                const span = textChange.span;
                const deletedText = originalContent.slice(span.start, span.start + textChange.span.length);
                const insertedText = textChange.newText;
                this.editScriptAndUpdateMarkers(fileName, span.start, span.start + insertedText.length, deletedText);
            }
        }
        if (expectedTextArray.length !== actualTextArray.length) {
            this.raiseError(`Expected ${expectedTextArray.length} import fixes, got ${actualTextArray.length}:\n\n${actualTextArray.join("\n\n" + "-".repeat(20) + "\n\n")}`);
        }
        ts.zipWith(expectedTextArray, actualTextArray, (expected, actual, index) => {
            if (expected !== actual) {
                this.raiseError(`Import fix at index ${index} doesn't match.\n${showTextDiff(expected, actual)}`);
            }
        });
    }

    public verifyImportFixModuleSpecifiers(markerName: string, moduleSpecifiers: string[], preferences?: ts.UserPreferences): void {
        const marker = this.getMarkerByName(markerName);
        const codeFixes = this.getCodeFixes(marker.fileName, ts.Diagnostics.Cannot_find_name_0.code, {
            includeCompletionsForModuleExports: true,
            includeCompletionsWithInsertText: true,
            ...preferences,
        }, marker.position).filter(f => f.fixName === ts.codefix.importFixName);

        const actualModuleSpecifiers = ts.mapDefined(codeFixes, fix => {
            return ts.forEach(ts.flatMap(fix.changes, c => c.textChanges), c => {
                const match = /(?:from |require\()(['"])((?:(?!\1).)*)\1/.exec(c.newText);
                return match?.[2];
            });
        });

        assert.deepEqual(actualModuleSpecifiers, moduleSpecifiers);
    }

    public verifyPreparePasteEdits(options: FourSlashInterface.PreparePasteEditsOptions): void {
        const providePasteEdits = this.languageService.preparePasteEditsForFile(options.copiedFromFile, options.copiedTextRange);
        if (providePasteEdits !== options.providePasteEdits) {
            this.raiseError(`preparePasteEdits failed - Expected prepare paste edits to return ${options.providePasteEdits}, but got ${providePasteEdits}.`);
        }
    }

    public verifyPasteEdits(options: FourSlashInterface.PasteEditsOptions): void {
        const editInfo = this.languageService.getPasteEdits({ targetFile: this.activeFile.fileName, pastedText: options.args.pastedText, pasteLocations: options.args.pasteLocations, copiedFrom: options.args.copiedFrom, preferences: options.args.preferences }, this.formatCodeSettings);
        this.verifyNewContent({ newFileContent: options.newFileContents }, editInfo.edits);
    }

    public verifyDocCommentTemplate(expected: ts.TextInsertion | undefined, options?: ts.DocCommentTemplateOptions): void {
        const name = "verifyDocCommentTemplate";
        const actual = this.languageService.getDocCommentTemplateAtPosition(this.activeFile.fileName, this.currentCaretPosition, options || { generateReturnInDocTemplate: true }, this.formatCodeSettings)!;

        if (expected === undefined) {
            if (actual) {
                this.raiseError(`${name} failed - expected no template but got {newText: "${actual.newText}", caretOffset: ${actual.caretOffset}}`);
            }

            return;
        }
        else {
            if (actual === undefined) {
                this.raiseError(`${name} failed - expected the template {newText: "${expected.newText}", caretOffset: "${expected.caretOffset}"} but got nothing instead`);
            }

            if (actual.newText !== expected.newText) {
                this.raiseError(`${name} failed for expected insertion.\n${showTextDiff(expected.newText, actual.newText)}`);
            }

            if (actual.caretOffset !== expected.caretOffset) {
                this.raiseError(`${name} failed - expected caretOffset: ${expected.caretOffset}\nactual caretOffset:${actual.caretOffset}`);
            }
        }
    }

    public verifyBraceCompletionAtPosition(negative: boolean, openingBrace: string): void {
        const openBraceMap = new Map(Object.entries<ts.CharacterCodes>({
            "(": ts.CharacterCodes.openParen,
            "{": ts.CharacterCodes.openBrace,
            "[": ts.CharacterCodes.openBracket,
            "'": ts.CharacterCodes.singleQuote,
            '"': ts.CharacterCodes.doubleQuote,
            "`": ts.CharacterCodes.backtick,
            "<": ts.CharacterCodes.lessThan,
        }));

        const charCode = openBraceMap.get(openingBrace);

        if (!charCode) {
            throw this.raiseError(`Invalid openingBrace '${openingBrace}' specified.`);
        }

        const position = this.currentCaretPosition;

        const validBraceCompletion = this.languageService.isValidBraceCompletionAtPosition(this.activeFile.fileName, position, charCode);

        if (!negative && !validBraceCompletion) {
            this.raiseError(`${position} is not a valid brace completion position for ${openingBrace}`);
        }
        if (negative && validBraceCompletion) {
            this.raiseError(`${position} is a valid brace completion position for ${openingBrace}`);
        }
    }

    public baselineAutoImports(markerName: string, fullNamesForCodeFix?: string[], preferences?: ts.UserPreferences): void {
        const marker = this.getMarkerByName(markerName);
        const completionPreferences = {
            includeCompletionsForModuleExports: true,
            includeCompletionsWithInsertText: true,
            allowIncompleteCompletions: true,
            includeCompletionsWithSnippetText: true,
            ...preferences,
        };

        this.goToMarker(marker);
        this.configure(completionPreferences);
        const fileName = this.activeFile.fileName;
        const ext = ts.getAnyExtensionFromPath(fileName).slice(1);
        const lang = ["mts", "cts"].includes(ext) ? "ts" : ext;
        let baselineText = codeFence(this.renderMarkers([{ text: "|", fileName: marker.fileName, position: marker.position }], /*useTerminalBoldSequence*/ false), lang) + "\n\n";

        const completions = this.getCompletionListAtCaret(completionPreferences)!;

        const autoImportCompletions = completions.entries.filter(c => c.hasAction && c.source && c.sortText === ts.Completions.SortText.AutoImportSuggestions);
        if (autoImportCompletions.length) {
            baselineText += `## From completions\n\n${autoImportCompletions.map(c => `- \`${c.name}\` from \`"${c.source}"\``).join("\n")}\n\n`;
            autoImportCompletions.forEach(c => {
                const details = this.getCompletionEntryDetails(c.name, c.source, c.data, completionPreferences);
                assert(details?.codeActions, `Entry '${c.name}' from "${c.source}" returned no code actions from completion details request`);
                assert(details.codeActions.length === 1, `Entry '${c.name}' from "${c.source}" returned more than one code action`);
                assert(details.codeActions[0].changes.length === 1, `Entry '${c.name}' from "${c.source}" returned a code action changing more than one file`);
                assert(details.codeActions[0].changes[0].fileName === this.activeFile.fileName, `Entry '${c.name}' from "${c.source}" returned a code action changing a different file`);
                const changes = details.codeActions[0].changes[0].textChanges;
                const completionChange: ts.TextChange = { newText: c.insertText || c.name, span: c.replacementSpan || completions.optionalReplacementSpan || { start: marker.position, length: 0 } };
                const sortedChanges = [...changes, completionChange].sort((a, b) => a.span.start - b.span.start);
                let newFileContent = this.activeFile.content;
                for (let i = sortedChanges.length - 1; i >= 0; i--) {
                    newFileContent = newFileContent.substring(0, sortedChanges[i].span.start) + sortedChanges[i].newText + newFileContent.substring(sortedChanges[i].span.start + sortedChanges[i].span.length);
                }
                baselineText += codeFence(newFileContent, lang) + "\n\n";
            });
        }

        if (fullNamesForCodeFix) {
            const scriptInfo = this.languageServiceAdapterHost.getScriptInfo(fileName)!;
            const originalContent = scriptInfo.content;
            const range = this.getRangesInFile()[0]
                || getRangeOfIdentifierTouchingPosition(this.activeFile.content, marker.position)
                || { pos: marker.position, end: marker.position };

            baselineText += `## From codefixes\n\n`;
            for (const fullNameForCodeFix of fullNamesForCodeFix) {
                this.applyEdits(fileName, [{ span: { start: 0, length: this.getFileContent(fileName).length }, newText: originalContent }]);
                this.applyEdits(fileName, [{ span: ts.createTextSpanFromRange(range), newText: fullNameForCodeFix }]);
                baselineText += `### When marker text is \`${fullNameForCodeFix}\`\n\n`;

                const codeFixes = this.getCodeFixes(fileName, /*errorCode*/ undefined, completionPreferences)
                    .filter(f => f.fixName === ts.codefix.importFixName);
                for (const fix of codeFixes) {
                    baselineText += fix.description + "\n";
                    if (fix.fixAllDescription) {
                        baselineText += `Fix all available: ${fix.fixAllDescription}\n`;
                    }
                    ts.Debug.assert(fix.changes.length === 1);
                    const change = ts.first(fix.changes);
                    ts.Debug.assert(change.fileName === fileName);
                    this.applyEdits(change.fileName, change.textChanges);
                    const text = this.getFileContent(fileName);
                    baselineText += "\n" + codeFence(text, lang) + "\n\n";
                }
            }
        }

        this.baseline("Auto Imports", baselineText, `.baseline.md`);
    }

    public verifyJsxClosingTag(map: { [markerName: string]: ts.JsxClosingTagInfo | undefined; }): void {
        for (const markerName in map) {
            this.goToMarker(markerName);
            const actual = this.languageService.getJsxClosingTagAtPosition(this.activeFile.fileName, this.currentCaretPosition);
            assert.deepEqual(actual, map[markerName], markerName);
        }
    }

    public verifyLinkedEditingRange(map: { [markerName: string]: ts.LinkedEditingInfo | undefined; }): void {
        for (const markerName in map) {
            this.goToMarker(markerName);
            const actual = this.languageService.getLinkedEditingRangeAtPosition(this.activeFile.fileName, this.currentCaretPosition);
            assert.deepEqual(actual, map[markerName], markerName);
        }
    }

    public baselineLinkedEditing(): void {
        const files = this.testData.files;

        let baselineContent = "";
        let offset = 0;
        for (const f of files) {
            const result = getLinkedEditingBaselineWorker(f, offset, this.languageService);
            baselineContent += result.baselineContent + `\n\n\n`;
            offset = result.offset;
        }

        this.baseline("Linked Editing", baselineContent, ".linkedEditing.txt");

        function getLinkedEditingBaselineWorker(activeFile: FourSlashFile, offset: number, languageService: ts.LanguageService) {
            const fileName = activeFile.fileName;
            let baselineContent = `=== ${fileName} ===\n`;

            // get linkedEdit at every position in the file, then group positions by their linkedEdit
            const linkedEditsInFile = new Map<string, number[]>();
            for (let pos = 0; pos < activeFile.content.length; pos++) {
                const linkedEditAtPosition = languageService.getLinkedEditingRangeAtPosition(fileName, pos);
                if (!linkedEditAtPosition) continue;

                const linkedEditString = JSON.stringify(linkedEditAtPosition);
                const existingPositions = linkedEditsInFile.get(linkedEditString) ?? [];
                linkedEditsInFile.set(linkedEditString, [...existingPositions, pos]);
            }

            const linkedEditsByRange = [...linkedEditsInFile.entries()].sort((a, b) => a[1][0] - b[1][0]);
            if (linkedEditsByRange.length === 0) {
                return { baselineContent: baselineContent + activeFile.content + `\n\n--No linked edits found--`, offset };
            }

            const inlineLinkedEditBaselines: { start: number; end: number; index: number; }[] = [];
            let linkedEditInfoBaseline = "";
            for (const edit of linkedEditsByRange) {
                const [linkedEdit, positions] = edit;
                let rangeStart = 0;
                for (let j = 0; j < positions.length - 1; j++) {
                    // for each distinct range in the list of positions, add an entry to the list of places that need to be annotated in the baseline
                    if (positions[j] + 1 !== positions[j + 1]) {
                        inlineLinkedEditBaselines.push({ start: positions[rangeStart], end: positions[j], index: offset });
                        rangeStart = j + 1;
                    }
                }
                inlineLinkedEditBaselines.push({ start: positions[rangeStart], end: positions[positions.length - 1], index: offset });

                // add the LinkedEditInfo with its index to the baseline
                linkedEditInfoBaseline += `\n\n=== ${offset} ===\n` + linkedEdit;
                offset++;
            }

            inlineLinkedEditBaselines.sort((a, b) => a.start - b.start);
            const fileText = activeFile.content;
            baselineContent += fileText.slice(0, inlineLinkedEditBaselines[0].start);
            for (let i = 0; i < inlineLinkedEditBaselines.length; i++) {
                const e = inlineLinkedEditBaselines[i];
                const sliceEnd = inlineLinkedEditBaselines[i + 1]?.start;
                baselineContent += `[|/*${e.index}*/` + fileText.slice(e.start, e.end) + `|]` + fileText.slice(e.end, sliceEnd);
            }

            baselineContent += linkedEditInfoBaseline;
            return { baselineContent, offset };
        }
    }

    public verifyMatchingBracePosition(bracePosition: number, expectedMatchPosition: number): void {
        const actual = this.languageService.getBraceMatchingAtPosition(this.activeFile.fileName, bracePosition);

        if (actual.length !== 2) {
            this.raiseError(`verifyMatchingBracePosition failed - expected result to contain 2 spans, but it had ${actual.length}`);
        }

        let actualMatchPosition = -1;
        if (bracePosition === actual[0].start) {
            actualMatchPosition = actual[1].start;
        }
        else if (bracePosition === actual[1].start) {
            actualMatchPosition = actual[0].start;
        }
        else {
            this.raiseError(`verifyMatchingBracePosition failed - could not find the brace position: ${bracePosition} in the returned list: (${actual[0].start},${ts.textSpanEnd(actual[0])}) and (${actual[1].start},${ts.textSpanEnd(actual[1])})`);
        }

        if (actualMatchPosition !== expectedMatchPosition) {
            this.raiseError(`verifyMatchingBracePosition failed - expected: ${actualMatchPosition},  actual: ${expectedMatchPosition}`);
        }
    }

    public verifyNoMatchingBracePosition(bracePosition: number): void {
        const actual = this.languageService.getBraceMatchingAtPosition(this.activeFile.fileName, bracePosition);

        if (actual.length !== 0) {
            this.raiseError("verifyNoMatchingBracePosition failed - expected: 0 spans, actual: " + actual.length);
        }
    }

    public verifySpanOfEnclosingComment(negative: boolean, onlyMultiLineDiverges?: boolean): void {
        const expected = !negative;
        const position = this.currentCaretPosition;
        const fileName = this.activeFile.fileName;
        const actual = !!this.languageService.getSpanOfEnclosingComment(fileName, position, /*onlyMultiLine*/ false);
        const actualOnlyMultiLine = !!this.languageService.getSpanOfEnclosingComment(fileName, position, /*onlyMultiLine*/ true);
        if (expected !== actual || onlyMultiLineDiverges === (actual === actualOnlyMultiLine)) {
            this.raiseError(`verifySpanOfEnclosingComment failed:
                position: '${position}'
                fileName: '${fileName}'
                onlyMultiLineDiverges: '${onlyMultiLineDiverges}'
                actual: '${actual}'
                actualOnlyMultiLine: '${actualOnlyMultiLine}'
                expected: '${expected}'.`);
        }
    }

    public verifyNavigateTo(options: readonly FourSlashInterface.VerifyNavigateToOptions[]): void {
        for (const { pattern, expected, fileName, excludeLibFiles } of options) {
            const file = fileName && this.findFile(fileName).fileName;
            const items = this.languageService.getNavigateToItems(pattern, /*maxResultCount*/ undefined, file, /*excludeDtsFiles*/ undefined, excludeLibFiles);
            this.assertObjectsEqual(
                items,
                expected.map((e): ts.NavigateToItem => ({
                    name: e.name,
                    kind: e.kind,
                    kindModifiers: e.kindModifiers || "",
                    matchKind: e.matchKind || "exact",
                    isCaseSensitive: e.isCaseSensitive === undefined ? true : e.isCaseSensitive,
                    fileName: e.range.fileName,
                    textSpan: ts.createTextSpanFromRange(e.range),
                    containerName: e.containerName || "",
                    containerKind: e.containerKind || ts.ScriptElementKind.unknown,
                })),
            );
        }
    }

    public verifyNavigationBar(json: any, options: { checkSpans?: boolean; } | undefined): void {
        this.verifyNavigationTreeOrBar(json, this.languageService.getNavigationBarItems(this.activeFile.fileName), "Bar", options);
    }

    public verifyNavigationTree(json: any, options: { checkSpans?: boolean; } | undefined): void {
        this.verifyNavigationTreeOrBar(json, this.languageService.getNavigationTree(this.activeFile.fileName), "Tree", options);
    }

    private verifyNavigationTreeOrBar(json: any, tree: any, name: "Tree" | "Bar", options: { checkSpans?: boolean; } | undefined) {
        if (JSON.stringify(tree, replacer) !== JSON.stringify(json)) {
            this.raiseError(`verifyNavigation${name} failed - \n${showTextDiff(stringify(json), stringify(tree, replacer))}`);
        }

        function replacer(key: string, value: any) {
            switch (key) {
                case "spans":
                case "nameSpan":
                    return options && options.checkSpans ? value : undefined;
                case "start":
                case "length":
                    // Never omit the values in a span, even if they are 0.
                    return value;
                case "childItems":
                    return !value || value.length === 0 ? undefined : value;
                default:
                    // Omit falsy values, those are presumed to be the default.
                    return value || undefined;
            }
        }
    }

    public printNavigationItems(searchValue: string): void {
        const items = this.languageService.getNavigateToItems(searchValue);
        Harness.IO.log(`NavigationItems list (${items.length} items)`);
        for (const item of items) {
            Harness.IO.log(`name: ${item.name}, kind: ${item.kind}, parentName: ${item.containerName}, fileName: ${item.fileName}`);
        }
    }

    public printNavigationBar(): void {
        const items = this.languageService.getNavigationBarItems(this.activeFile.fileName);
        Harness.IO.log(`Navigation bar (${items.length} items)`);
        for (const item of items) {
            Harness.IO.log(`${ts.repeatString(" ", item.indent)}name: ${item.text}, kind: ${item.kind}, childItems: ${item.childItems.map(child => child.text)}`);
        }
    }

    private getDocumentHighlightsAtCurrentPosition(fileNamesToSearch: readonly string[]) {
        const filesToSearch = fileNamesToSearch.map(name => ts.combinePaths(this.basePath, name));
        return this.languageService.getDocumentHighlights(this.activeFile.fileName, this.currentCaretPosition, filesToSearch);
    }

    public baselineDocumentHighlights(
        markerOrRange: ArrayOrSingle<MarkerOrNameOrRange> | undefined,
        rangeText: ArrayOrSingle<string> | undefined,
        options: FourSlashInterface.VerifyDocumentHighlightsOptions | undefined,
    ): void {
        this.baselineEachMarkerOrRangeArrayOrSingle(
            "documentHighlights",
            markerOrRange,
            rangeText,
            markerOrRange => {
                this.goToMarkerOrNameOrRange(markerOrRange);
                const highlights = this.getDocumentHighlightsAtCurrentPosition(ts.map(options?.filesToSearch, ts.normalizePath) || [this.activeFile.fileName]);

                // Write input files
                const filesToSearch = options ? "// filesToSearch:\n" +
                    options.filesToSearch.map(f => "//   " + f).join("\n") + "\n\n" :
                    "";
                const baselineContent = this.getBaselineForGroupedDocumentSpansWithFileContents(
                    highlights?.map(h => h.highlightSpans.map(s => s.fileName ? s as ts.DocumentSpan : { ...s, fileName: h.fileName })) || ts.emptyArray,
                    { markerInfo: { markerOrRange, markerName: "/*HIGHLIGHTS*/" } },
                );
                return filesToSearch + baselineContent;
            },
        );
    }

    public verifyCodeFixAvailable(negative: boolean, expected: FourSlashInterface.VerifyCodeFixAvailableOptions[] | string | undefined): void {
        const codeFixes = this.getCodeFixes(this.activeFile.fileName);
        if (negative) {
            if (typeof expected === "undefined") {
                this.assertObjectsEqual(codeFixes, ts.emptyArray);
            }
            else if (typeof expected === "string") {
                if (codeFixes.some(fix => fix.fixName === expected)) {
                    this.raiseError(`Expected not to find a fix with the name '${expected}', but one exists.`);
                }
            }
            else {
                assert(typeof expected === "undefined" || typeof expected === "string", "With a negated assertion, 'expected' must be undefined or a string value of a codefix name.");
            }
        }
        else if (typeof expected === "string") {
            this.assertObjectsEqual(codeFixes.map(fix => fix.fixName), [expected]);
        }
        else {
            const actuals = codeFixes.map((fix): FourSlashInterface.VerifyCodeFixAvailableOptions => ({ description: fix.description, commands: fix.commands }));
            this.assertObjectsEqual(actuals, negative ? ts.emptyArray : expected);
        }
    }

    public verifyCodeFixAllAvailable(negative: boolean, fixName: string): void {
        const availableFixes = this.getCodeFixes(this.activeFile.fileName);
        const hasFix = availableFixes.some(fix => fix.fixName === fixName && fix.fixId);
        if (negative && hasFix) {
            this.raiseError(`Expected not to find a fix with the name '${fixName}', but one exists.`);
        }
        else if (!negative && !hasFix) {
            if (availableFixes.some(fix => fix.fixName === fixName)) {
                this.raiseError(`Found a fix with the name '${fixName}', but fix-all is not available.`);
            }

            this.raiseError(
                `Expected to find a fix with the name '${fixName}', but none exists.` +
                    availableFixes.length
                    ? ` Available fixes: ${availableFixes.map(fix => `${fix.fixName} (${fix.fixId ? "with" : "without"} fix-all)`).join(", ")}`
                    : "",
            );
        }
    }

    public verifyApplicableRefactorAvailableAtMarker(negative: boolean, markerName: string): void {
        const isAvailable = this.getApplicableRefactors(this.getMarkerByName(markerName)).length > 0;
        if (negative && isAvailable) {
            this.raiseError(`verifyApplicableRefactorAvailableAtMarker failed - expected no refactor at marker ${markerName} but found some.`);
        }
        if (!negative && !isAvailable) {
            this.raiseError(`verifyApplicableRefactorAvailableAtMarker failed - expected a refactor at marker ${markerName} but found none.`);
        }
    }

    private getSelection(): ts.TextRange {
        return {
            pos: this.currentCaretPosition,
            end: this.selectionEnd === -1 ? this.currentCaretPosition : this.selectionEnd,
        };
    }

    public verifyRefactorAvailable(negative: boolean, triggerReason: ts.RefactorTriggerReason, name: string, actionName?: string, actionDescription?: string, kind?: string, preferences: {} = ts.emptyOptions, includeInteractiveActions?: boolean): void {
        let refactors = this.getApplicableRefactorsAtSelection(triggerReason, kind, preferences, includeInteractiveActions);
        refactors = refactors.filter(r => r.name === name);

        if (actionName !== undefined) {
            refactors.forEach(r => r.actions = r.actions.filter(a => a.name === actionName));
        }

        if (actionDescription !== undefined) {
            refactors.forEach(r => r.actions = r.actions.filter(a => a.description === actionDescription));
        }

        refactors = refactors.filter(r => r.actions.length > 0);

        const isAvailable = refactors.length > 0;

        if (negative) {
            if (isAvailable) {
                this.raiseError(`verifyApplicableRefactorAvailableForRange failed - expected no refactor but found: ${refactors.map(r => r.name).join(", ")}`);
            }
        }
        else {
            if (!isAvailable) {
                this.raiseError(`verifyApplicableRefactorAvailableForRange failed - expected a refactor but found none.`);
            }
            if (refactors.length > 1) {
                this.raiseError(`${refactors.length} available refactors both have name ${name} and action ${actionName}`);
            }
        }
    }

    public verifyRefactorKindsAvailable(kind: string, expected: string[], preferences: {} = ts.emptyOptions): void {
        const refactors = this.getApplicableRefactorsAtSelection("invoked", kind, preferences);
        const availableKinds = ts.flatMap(refactors, refactor => refactor.actions).map(action => action.kind);
        assert.deepEqual(availableKinds.slice().sort(), expected.slice().sort(), `Expected kinds to be equal`);
    }

    public verifyRefactorsAvailable(names: readonly string[]): void {
        assert.deepEqual(unique(this.getApplicableRefactorsAtSelection(), r => r.name), names);
    }

    public verifyApplicableRefactorAvailableForRange(negative: boolean): void {
        const ranges = this.getRanges();
        if (!(ranges && ranges.length === 1)) {
            throw new Error("Exactly one refactor range is allowed per test.");
        }

        const isAvailable = this.getApplicableRefactors(ts.first(ranges)).length > 0;
        if (negative && isAvailable) {
            this.raiseError(`verifyApplicableRefactorAvailableForRange failed - expected no refactor but found some.`);
        }
        if (!negative && !isAvailable) {
            this.raiseError(`verifyApplicableRefactorAvailableForRange failed - expected a refactor but found none.`);
        }
    }

    public applyRefactor({ refactorName, actionName, actionDescription, newContent: newContentWithRenameMarker, triggerReason }: FourSlashInterface.ApplyRefactorOptions): void {
        const range = this.getSelection();
        const refactors = this.getApplicableRefactorsAtSelection(triggerReason);
        const refactorsWithName = refactors.filter(r => r.name === refactorName);
        if (refactorsWithName.length === 0) {
            this.raiseError(`The expected refactor: ${refactorName} is not available at the marker location.\nAvailable refactors: ${refactors.map(r => r.name)}`);
        }

        const action = ts.firstDefined(refactorsWithName, refactor => refactor.actions.find(a => a.name === actionName));
        if (!action) {
            throw this.raiseError(`The expected action: ${actionName} is not included in: ${ts.flatMap(refactorsWithName, r => r.actions.map(a => a.name))}`);
        }
        if (action.description !== actionDescription) {
            this.raiseError(`Expected action description to be ${JSON.stringify(actionDescription)}, got: ${JSON.stringify(action.description)}`);
        }

        const editInfo = this.languageService.getEditsForRefactor(this.activeFile.fileName, this.formatCodeSettings, range, refactorName, actionName, ts.emptyOptions)!;
        for (const edit of editInfo.edits) {
            this.applyEdits(edit.fileName, edit.textChanges);
        }

        let renameFilename: string | undefined;
        let renamePosition: number | undefined;

        const newFileContents = typeof newContentWithRenameMarker === "string" ? { [this.activeFile.fileName]: newContentWithRenameMarker } : newContentWithRenameMarker;
        for (const fileName in newFileContents) {
            const { renamePosition: rp, newContent } = TestState.parseNewContent(newFileContents[fileName]);
            if (renamePosition === undefined) {
                renameFilename = fileName;
                renamePosition = rp;
            }
            else {
                ts.Debug.assert(rp === undefined);
            }
            this.verifyFileContent(fileName, newContent);
        }

        if (renamePosition === undefined) {
            if (editInfo.renameLocation !== undefined) {
                this.raiseError(`Did not expect a rename location, got ${editInfo.renameLocation}`);
            }
        }
        else {
            this.assertObjectsEqual(editInfo.renameFilename, renameFilename);
            if (renamePosition !== editInfo.renameLocation) {
                this.raiseError(`Expected rename position of ${renamePosition}, but got ${editInfo.renameLocation}`);
            }
        }
    }

    private static parseNewContent(newContentWithRenameMarker: string): { readonly renamePosition: number | undefined; readonly newContent: string; } {
        const renamePosition = newContentWithRenameMarker.indexOf("/*RENAME*/");
        if (renamePosition === -1) {
            return { renamePosition: undefined, newContent: newContentWithRenameMarker };
        }
        else {
            const newContent = newContentWithRenameMarker.slice(0, renamePosition) + newContentWithRenameMarker.slice(renamePosition + "/*RENAME*/".length);
            return { renamePosition, newContent };
        }
    }

    public noMoveToNewFile(): void {
        const ranges = this.getRanges();
        assert(ranges.length);
        for (const range of ranges) {
            for (const refactor of this.getApplicableRefactors(range, { allowTextChangesInNewFiles: true })) {
                if (refactor.name === "Move to a new file") {
                    ts.Debug.fail("Did not expect to get 'move to a new file' refactor");
                }
            }
        }
    }

    public moveToNewFile(options: FourSlashInterface.MoveToNewFileOptions): void {
        assert(this.getRanges().length === 1, "Must have exactly one fourslash range (source enclosed between '[|' and '|]' delimiters) in the source file");
        const range = this.getRanges()[0];
        const refactor = ts.find(this.getApplicableRefactors(range, { allowTextChangesInNewFiles: true }), r => r.name === "Move to a new file")!;
        assert(refactor.actions.length === 1);
        const action = ts.first(refactor.actions);
        assert(action.name === "Move to a new file" && action.description === "Move to a new file");

        const editInfo = this.languageService.getEditsForRefactor(range.fileName, this.formatCodeSettings, range, refactor.name, action.name, options.preferences || ts.emptyOptions)!;
        this.verifyNewContent({ newFileContent: options.newFileContents }, editInfo.edits);
    }

    public moveToFile(options: FourSlashInterface.MoveToFileOptions): void {
        assert(this.getRanges().length === 1, "Must have exactly one fourslash range (source enclosed between '[|' and '|]' delimiters) in the source file");
        const range = this.getRanges()[0];
        const refactor = ts.find(this.getApplicableRefactors(range, { allowTextChangesInNewFiles: true }, /*triggerReason*/ undefined, /*kind*/ undefined, /*includeInteractiveActions*/ true), r => r.name === "Move to file")!;
        assert(refactor.actions.length === 1);
        const action = ts.first(refactor.actions);
        assert(action.name === "Move to file" && action.description === "Move to file");

        const editInfo = this.languageService.getEditsForRefactor(range.fileName, this.formatCodeSettings, range, refactor.name, action.name, options.preferences || ts.emptyOptions, options.interactiveRefactorArguments)!;
        this.verifyNewContent({ newFileContent: options.newFileContents }, editInfo.edits);
    }

    private testNewFileContents(edits: readonly ts.FileTextChanges[], newFileContents: { [fileName: string]: string; }, description: string): void {
        for (const { fileName, textChanges } of edits) {
            const newContent = newFileContents[fileName];
            if (newContent === undefined) {
                this.raiseError(`${description} - There was an edit in ${fileName} but new content was not specified.`);
            }

            const fileContent = this.tryGetFileContent(fileName);
            if (fileContent !== undefined) {
                const actualNewContent = ts.textChanges.applyChanges(fileContent, textChanges);
                assert.equal(actualNewContent, newContent, `new content for ${fileName}`);
            }
            else {
                // Creates a new file.
                assert(textChanges.length === 1);
                const change = ts.first(textChanges);
                assert.deepEqual(change.span, ts.createTextSpan(0, 0));
                assert.equal(change.newText, newContent, `${description} - Content for ${fileName}`);
            }
        }

        for (const fileName in newFileContents) {
            if (!edits.some(e => e.fileName === fileName)) {
                ts.Debug.fail(`${description} - Asserted new contents of ${fileName} but there were no edits`);
            }
        }
    }

    public verifyFileAfterApplyingRefactorAtMarker(
        markerName: string,
        expectedContent: string,
        refactorNameToApply: string,
        actionName: string,
        formattingOptions?: ts.FormatCodeSettings,
    ): void {
        formattingOptions = formattingOptions || this.formatCodeSettings;
        const marker = this.getMarkerByName(markerName);

        const applicableRefactors = this.languageService.getApplicableRefactors(this.activeFile.fileName, marker.position, ts.emptyOptions);
        const applicableRefactorToApply = ts.find(applicableRefactors, refactor => refactor.name === refactorNameToApply);

        if (!applicableRefactorToApply) {
            this.raiseError(`The expected refactor: ${refactorNameToApply} is not available at the marker location.`);
        }

        const editInfo = this.languageService.getEditsForRefactor(marker.fileName, formattingOptions, marker.position, refactorNameToApply, actionName, ts.emptyOptions)!;

        for (const edit of editInfo.edits) {
            this.applyEdits(edit.fileName, edit.textChanges);
        }
        const actualContent = this.getFileContent(marker.fileName);

        if (actualContent !== expectedContent) {
            this.raiseError(`verifyFileAfterApplyingRefactors failed:\n${showTextDiff(expectedContent, actualContent)}`);
        }
    }

    public printAvailableCodeFixes(): void {
        const codeFixes = this.getCodeFixes(this.activeFile.fileName);
        Harness.IO.log(stringify(codeFixes));
    }

    private formatCallHierarchyItemSpan(file: FourSlashFile, span: ts.TextSpan, prefix: string, trailingPrefix = prefix) {
        const startLc = this.languageServiceAdapterHost.positionToLineAndCharacter(file.fileName, span.start);
        const endLc = this.languageServiceAdapterHost.positionToLineAndCharacter(file.fileName, ts.textSpanEnd(span));
        const lines = this.spanLines(file, span, { fullLines: true, lineNumbers: true, selection: true });
        let text = "";
        text += `${prefix}╭ ${file.fileName}:${startLc.line + 1}:${startLc.character + 1}-${endLc.line + 1}:${endLc.character + 1}\n`;
        for (const line of lines) {
            text += `${prefix}│ ${line.trimRight()}\n`;
        }
        text += `${trailingPrefix}╰\n`;
        return text;
    }

    private formatCallHierarchyItemSpans(file: FourSlashFile, spans: ts.TextSpan[], prefix: string, trailingPrefix = prefix) {
        let text = "";
        for (let i = 0; i < spans.length; i++) {
            text += this.formatCallHierarchyItemSpan(file, spans[i], prefix, i < spans.length - 1 ? prefix : trailingPrefix);
        }
        return text;
    }

    private formatCallHierarchyItem(file: FourSlashFile, callHierarchyItem: ts.CallHierarchyItem, direction: CallHierarchyItemDirection, seen: Map<string, boolean>, prefix: string, trailingPrefix: string = prefix) {
        const key = `${callHierarchyItem.file}|${JSON.stringify(callHierarchyItem.span)}|${direction}`;
        const alreadySeen = seen.has(key);
        seen.set(key, true);

        const incomingCalls = direction === CallHierarchyItemDirection.Outgoing ? { result: "skip" } as const :
            alreadySeen ? { result: "seen" } as const :
            { result: "show", values: this.languageService.provideCallHierarchyIncomingCalls(callHierarchyItem.file, callHierarchyItem.selectionSpan.start) } as const;

        const outgoingCalls = direction === CallHierarchyItemDirection.Incoming ? { result: "skip" } as const :
            alreadySeen ? { result: "seen" } as const :
            { result: "show", values: this.languageService.provideCallHierarchyOutgoingCalls(callHierarchyItem.file, callHierarchyItem.selectionSpan.start) } as const;

        let text = "";
        text += `${prefix}╭ name: ${callHierarchyItem.name}\n`;
        text += `${prefix}├ kind: ${callHierarchyItem.kind}\n`;
        if (callHierarchyItem.containerName) {
            text += `${prefix}├ containerName: ${callHierarchyItem.containerName}\n`;
        }
        text += `${prefix}├ file: ${callHierarchyItem.file}\n`;
        text += `${prefix}├ span:\n`;
        text += this.formatCallHierarchyItemSpan(file, callHierarchyItem.span, `${prefix}│ `);
        text += `${prefix}├ selectionSpan:\n`;
        text += this.formatCallHierarchyItemSpan(
            file,
            callHierarchyItem.selectionSpan,
            `${prefix}│ `,
            incomingCalls.result !== "skip" || outgoingCalls.result !== "skip" ? `${prefix}│ ` :
                `${trailingPrefix}╰ `,
        );

        if (incomingCalls.result === "seen") {
            if (outgoingCalls.result === "skip") {
                text += `${trailingPrefix}╰ incoming: ...\n`;
            }
            else {
                text += `${prefix}├ incoming: ...\n`;
            }
        }
        else if (incomingCalls.result === "show") {
            if (!ts.some(incomingCalls.values)) {
                if (outgoingCalls.result === "skip") {
                    text += `${trailingPrefix}╰ incoming: none\n`;
                }
                else {
                    text += `${prefix}├ incoming: none\n`;
                }
            }
            else {
                text += `${prefix}├ incoming:\n`;
                for (let i = 0; i < incomingCalls.values.length; i++) {
                    const incomingCall = incomingCalls.values[i];
                    const file = this.findFile(incomingCall.from.file);
                    text += `${prefix}│ ╭ from:\n`;
                    text += this.formatCallHierarchyItem(file, incomingCall.from, CallHierarchyItemDirection.Incoming, seen, `${prefix}│ │ `);
                    text += `${prefix}│ ├ fromSpans:\n`;
                    text += this.formatCallHierarchyItemSpans(
                        file,
                        incomingCall.fromSpans,
                        `${prefix}│ │ `,
                        i < incomingCalls.values.length - 1 ? `${prefix}│ ╰ ` :
                            outgoingCalls.result !== "skip" ? `${prefix}│ ╰ ` :
                            `${trailingPrefix}╰ ╰ `,
                    );
                }
            }
        }

        if (outgoingCalls.result === "seen") {
            text += `${trailingPrefix}╰ outgoing: ...\n`;
        }
        else if (outgoingCalls.result === "show") {
            if (!ts.some(outgoingCalls.values)) {
                text += `${trailingPrefix}╰ outgoing: none\n`;
            }
            else {
                text += `${prefix}├ outgoing:\n`;
                for (let i = 0; i < outgoingCalls.values.length; i++) {
                    const outgoingCall = outgoingCalls.values[i];
                    text += `${prefix}│ ╭ to:\n`;
                    text += this.formatCallHierarchyItem(this.findFile(outgoingCall.to.file), outgoingCall.to, CallHierarchyItemDirection.Outgoing, seen, `${prefix}│ │ `);
                    text += `${prefix}│ ├ fromSpans:\n`;
                    text += this.formatCallHierarchyItemSpans(
                        file,
                        outgoingCall.fromSpans,
                        `${prefix}│ │ `,
                        i < outgoingCalls.values.length - 1 ? `${prefix}│ ╰ ` :
                            `${trailingPrefix}╰ ╰ `,
                    );
                }
            }
        }
        return text;
    }

    private formatCallHierarchy(callHierarchyItem: ts.CallHierarchyItem | undefined) {
        let text = "";
        if (callHierarchyItem) {
            const file = this.findFile(callHierarchyItem.file);
            text += this.formatCallHierarchyItem(file, callHierarchyItem, CallHierarchyItemDirection.Root, new Map(), "");
        }
        return text;
    }

    public baselineCallHierarchy(): void {
        const callHierarchyItem = this.languageService.prepareCallHierarchy(this.activeFile.fileName, this.currentCaretPosition);
        const text = callHierarchyItem ? ts.mapOneOrMany(callHierarchyItem, item => this.formatCallHierarchy(item), result => result.join("")) : "none";
        this.baseline("Call Hierarchy", text, ".callHierarchy.txt");
    }

    private getLineContent(index: number) {
        const text = this.getFileContent(this.activeFile.fileName);
        const pos = this.languageServiceAdapterHost.lineAndCharacterToPosition(this.activeFile.fileName, { line: index, character: 0 });
        let startPos = pos, endPos = pos;

        while (startPos > 0) {
            const ch = text.charCodeAt(startPos - 1);
            if (ch === ts.CharacterCodes.carriageReturn || ch === ts.CharacterCodes.lineFeed) {
                break;
            }

            startPos--;
        }

        while (endPos < text.length) {
            const ch = text.charCodeAt(endPos);

            if (ch === ts.CharacterCodes.carriageReturn || ch === ts.CharacterCodes.lineFeed) {
                break;
            }

            endPos++;
        }

        return text.substring(startPos, endPos);
    }

    // Get the text of the entire line the caret is currently at
    private getCurrentLineContent() {
        return this.getLineContent(
            this.languageServiceAdapterHost.positionToLineAndCharacter(
                this.activeFile.fileName,
                this.currentCaretPosition,
            ).line,
        );
    }

    private findFile(indexOrName: string | number): FourSlashFile {
        if (typeof indexOrName === "number") {
            const index = indexOrName;
            if (index >= this.testData.files.length) {
                throw new Error(`File index (${index}) in openFile was out of range. There are only ${this.testData.files.length} files in this test.`);
            }
            else {
                return this.testData.files[index];
            }
        }
        else if (ts.isString(indexOrName)) {
            const { file, availableNames } = this.tryFindFileWorker(indexOrName);
            if (!file) {
                throw new Error(`No test file named "${indexOrName}" exists. Available file names are: ${availableNames.join(", ")}`);
            }
            return file;
        }
        else {
            return ts.Debug.assertNever(indexOrName);
        }
    }

    private tryFindFileWorker(name: string): { readonly file: FourSlashFile | undefined; readonly availableNames: readonly string[]; } {
        name = ts.normalizePath(name);
        // names are stored in the compiler with this relative path, this allows people to use goTo.file on just the fileName
        name = name.includes("/") ? name : (this.basePath + "/" + name);

        const availableNames: string[] = [];
        const file = ts.forEach(this.testData.files, file => {
            const fn = ts.normalizePath(file.fileName);
            if (fn) {
                if (fn === name) {
                    return file;
                }
                availableNames.push(fn);
            }
        });
        return { file, availableNames };
    }

    private hasFile(name: string): boolean {
        return this.tryFindFileWorker(name).file !== undefined;
    }

    private getLineColStringAtPosition(position: number, file: FourSlashFile = this.activeFile) {
        const pos = this.languageServiceAdapterHost.positionToLineAndCharacter(file.fileName, position);
        return `line ${(pos.line + 1)}, col ${pos.character}`;
    }

    public getMarkerByName(markerName: string): Marker {
        const markerPos = this.testData.markerPositions.get(markerName);
        if (markerPos === undefined) {
            throw new Error(`Unknown marker "${markerName}" Available markers: ${this.getMarkerNames().map(m => '"' + m + '"').join(", ")}`);
        }
        else {
            return markerPos;
        }
    }

    public setCancelled(numberOfCalls: number): void {
        this.cancellationToken.setCancelled(numberOfCalls);
    }

    public resetCancelled(): void {
        this.cancellationToken.resetCancelled();
    }

    public getEditsForFileRename({ oldPath, newPath, newFileContents, preferences }: FourSlashInterface.GetEditsForFileRenameOptions): void {
        const test = (fileContents: { readonly [fileName: string]: string; }, description: string): void => {
            const changes = this.languageService.getEditsForFileRename(oldPath, newPath, this.formatCodeSettings, preferences);
            this.testNewFileContents(changes, fileContents, description);
        };

        ts.Debug.assert(!this.hasFile(newPath), "initially, newPath should not exist");

        test(newFileContents, "with file not yet moved");

        this.languageServiceAdapterHost.renameFileOrDirectory(oldPath, newPath);
        this.languageService.cleanupSemanticCache();
        const pathUpdater = ts.getPathUpdater(oldPath, newPath, ts.createGetCanonicalFileName(/*useCaseSensitiveFileNames*/ false), /*sourceMapper*/ undefined);
        test(renameKeys(newFileContents, key => pathUpdater(key) || key), "with file moved");
    }

    private getApplicableRefactorsAtSelection(triggerReason: ts.RefactorTriggerReason = "implicit", kind?: string, preferences = ts.emptyOptions, includeInteractiveActions?: boolean) {
        return this.getApplicableRefactorsWorker(this.getSelection(), this.activeFile.fileName, preferences, triggerReason, kind, includeInteractiveActions);
    }
    private getApplicableRefactors(rangeOrMarker: Range | Marker, preferences = ts.emptyOptions, triggerReason: ts.RefactorTriggerReason = "implicit", kind?: string, includeInteractiveActions?: boolean): readonly ts.ApplicableRefactorInfo[] {
        return this.getApplicableRefactorsWorker("position" in rangeOrMarker ? rangeOrMarker.position : rangeOrMarker, rangeOrMarker.fileName, preferences, triggerReason, kind, includeInteractiveActions); // eslint-disable-line local/no-in-operator
    }
    private getApplicableRefactorsWorker(positionOrRange: number | ts.TextRange, fileName: string, preferences = ts.emptyOptions, triggerReason: ts.RefactorTriggerReason, kind?: string, includeInteractiveActions?: boolean): readonly ts.ApplicableRefactorInfo[] {
        return this.languageService.getApplicableRefactors(fileName, positionOrRange, preferences, triggerReason, kind, includeInteractiveActions) || ts.emptyArray;
    }

    public configurePlugin(pluginName: string, configuration: any): void {
        (this.languageService as ts.server.SessionClient).configurePlugin(pluginName, configuration);
    }

    public setCompilerOptionsForInferredProjects(options: ts.server.protocol.CompilerOptions): void {
        ts.Debug.assert(this.testType === FourSlashTestType.Server);
        (this.languageService as ts.server.SessionClient).setCompilerOptionsForInferredProjects(options);
    }

    public toggleLineComment(newFileContent: string): void {
        const changes: ts.TextChange[] = [];
        for (const range of this.getRanges()) {
            changes.push(...this.languageService.toggleLineComment(this.activeFile.fileName, range));
        }

        this.applyEdits(this.activeFile.fileName, changes);

        this.verifyCurrentFileContent(newFileContent);
    }

    public toggleMultilineComment(newFileContent: string): void {
        const changes: ts.TextChange[] = [];
        for (const range of this.getRanges()) {
            changes.push(...this.languageService.toggleMultilineComment(this.activeFile.fileName, range));
        }

        this.applyEdits(this.activeFile.fileName, changes);

        this.verifyCurrentFileContent(newFileContent);
    }

    public commentSelection(newFileContent: string): void {
        const changes: ts.TextChange[] = [];
        for (const range of this.getRanges()) {
            changes.push(...this.languageService.commentSelection(this.activeFile.fileName, range));
        }

        this.applyEdits(this.activeFile.fileName, changes);

        this.verifyCurrentFileContent(newFileContent);
    }

    public uncommentSelection(newFileContent: string): void {
        const changes: ts.TextChange[] = [];
        for (const range of this.getRanges()) {
            changes.push(...this.languageService.uncommentSelection(this.activeFile.fileName, range));
        }

        this.applyEdits(this.activeFile.fileName, changes);

        this.verifyCurrentFileContent(newFileContent);
    }

    public baselineMapCode(
        ranges: Range[][],
        changes: string[] = [],
    ): void {
        const fileName = this.activeFile.fileName;
        const focusLocations = ranges.map(r =>
            r.map(({ pos, end }) => {
                return { start: pos, length: end - pos };
            })
        );
        let before = this.getFileContent(fileName);
        const edits = this.languageService.mapCode(
            fileName,
            // We trim the leading whitespace stuff just so our test cases can be more readable.
            changes,
            focusLocations,
            this.formatCodeSettings,
            {},
        );
        this.applyChanges(edits);
        focusLocations.forEach(r => {
            r.sort((a, b) => a.start - b.start);
        });
        focusLocations.sort((a, b) => a[0].start - b[0].start);
        for (const subLoc of focusLocations) {
            for (const { start, length } of subLoc) {
                let offset = 0;
                for (const sl2 of focusLocations) {
                    for (const { start: s2, length: l2 } of sl2) {
                        if (s2 < start) {
                            offset += 4;
                            if ((s2 + l2) > start) {
                                offset -= 2;
                            }
                        }
                    }
                }
                before = before.slice(0, start + offset) + "[|" + before.slice(start + offset, start + offset + length) + "|]" + before.slice(start + offset + length);
            }
        }
        const after = this.getFileContent(fileName);
        const baseline = `
// === ORIGINAL ===
${before}
// === INCOMING CHANGES ===
${changes.join("\n// ---\n")}
// === MAPPED ===
${after}`;
        this.baseline("mapCode", baseline, ".mapCode.ts");
    }
}

function updateTextRangeForTextChanges({ pos, end }: ts.TextRange, textChanges: readonly ts.TextChange[]): ts.TextRange {
    forEachTextChange(textChanges, change => {
        const update = (p: number): number => updatePosition(p, change.span.start, ts.textSpanEnd(change.span), change.newText);
        pos = update(pos);
        end = update(end);
    });
    return { pos, end };
}

/** Apply each textChange in order, updating future changes to account for the text offset of previous changes. */
function forEachTextChange(changes: readonly ts.TextChange[], cb: (change: ts.TextChange) => void): void {
    // Copy this so we don't ruin someone else's copy
    changes = JSON.parse(JSON.stringify(changes));
    for (let i = 0; i < changes.length; i++) {
        const change = changes[i];
        cb(change);
        const changeDelta = change.newText.length - change.span.length;
        for (let j = i + 1; j < changes.length; j++) {
            if (changes[j].span.start >= change.span.start) {
                changes[j].span.start += changeDelta;
            }
        }
    }
}

function updatePosition(position: number, editStart: number, editEnd: number, { length }: string): number {
    // If inside the edit, return -1 to mark as invalid
    return position <= editStart ? position : position < editEnd ? -1 : position + length - +(editEnd - editStart);
}

function renameKeys<T>(obj: { readonly [key: string]: T; }, renameKey: (key: string) => string): { readonly [key: string]: T; } {
    const res: { [key: string]: T; } = {};
    for (const key in obj) {
        res[renameKey(key)] = obj[key];
    }
    return res;
}

export interface FourSlashServerLogBaseliner {
    baseline?: () => void;
}

export function runFourSlashTest(basePath: string, testType: FourSlashTestType, fileName: string, serverLogBaseliner?: FourSlashServerLogBaseliner): void {
    const content = Harness.IO.readFile(fileName)!;
    runFourSlashTestContent(basePath, testType, content, fileName, serverLogBaseliner);
}

export function runFourSlashTestContent(basePath: string, testType: FourSlashTestType, content: string, fileName: string, serverLogBaseliner?: FourSlashServerLogBaseliner): void {
    // Give file paths an absolute path for the virtual file system
    const absoluteBasePath = ts.combinePaths(Harness.virtualFileSystemRoot, basePath);
    const absoluteFileName = ts.combinePaths(Harness.virtualFileSystemRoot, fileName);

    // Parse out the files and their metadata
    const testData = parseTestData(absoluteBasePath, content, absoluteFileName);
    const state = new TestState(absoluteFileName, absoluteBasePath, testType, testData);
    if (serverLogBaseliner) serverLogBaseliner.baseline = () => state.baselineTsserverLog();
    const actualFileName = Harness.IO.resolvePath(fileName) || absoluteFileName;
    const output = ts.transpileModule(content, { reportDiagnostics: true, fileName: actualFileName, compilerOptions: { target: ts.ScriptTarget.ES2015, inlineSourceMap: true, inlineSources: true } });
    if (output.diagnostics!.length > 0) {
        throw new Error(`Syntax error in ${absoluteBasePath}: ${output.diagnostics![0].messageText}`);
    }
    runCode(output.outputText, state, actualFileName);
    state.baselineTest();
}

function runCode(code: string, state: TestState, fileName: string): void {
    // Compile and execute the test
    const generatedFile = ts.changeExtension(fileName, ".js");
    const wrappedCode = `(function(ts, test, goTo, config, verify, edit, debug, format, cancellation, classification, completion, verifyOperationIsCancelled, ignoreInterpolations) {${code}\n//# sourceURL=${ts.getBaseFileName(generatedFile)}\n})`;

    // Provide the content of the current test to 'source-map-support' so that it can give us the correct source positions
    // for test failures.
    sourceMapSupport.install({
        retrieveFile: path => {
            return path === generatedFile ? wrappedCode :
                undefined!;
        },
    });

    try {
        const test = new FourSlashInterface.Test(state);
        const goTo = new FourSlashInterface.GoTo(state);
        const config = new FourSlashInterface.Config(state);
        const verify = new FourSlashInterface.Verify(state);
        const edit = new FourSlashInterface.Edit(state);
        const debug = new FourSlashInterface.Debug(state);
        const format = new FourSlashInterface.Format(state);
        const cancellation = new FourSlashInterface.Cancellation(state);
        // eslint-disable-next-line no-eval
        const f = (0, eval)(wrappedCode);
        f(ts, test, goTo, config, verify, edit, debug, format, cancellation, FourSlashInterface.classification, FourSlashInterface.Completion, verifyOperationIsCancelled, ignoreInterpolations);
    }
    catch (err) {
        // ensure 'source-map-support' is triggered while we still have the handler attached by accessing `error.stack`.
        err.stack?.toString();
        throw err;
    }
    finally {
        sourceMapSupport.resetRetrieveHandlers();
    }
}

function chompLeadingSpace(content: string) {
    const lines = content.split("\n");
    for (const line of lines) {
        if ((line.length !== 0) && (line.charAt(0) !== " ")) {
            return content;
        }
    }

    return lines.map(s => s.substr(1)).join("\n");
}

function parseTestData(basePath: string, contents: string, fileName: string): FourSlashData {
    // Regex for parsing options in the format "@Alpha: Value of any sort"
    const optionRegex = /^\s*@(\w+):\s*(.*)\s*/;

    // List of all the subfiles we've parsed out
    const files: FourSlashFile[] = [];
    // Global options
    const globalOptions: { [s: string]: string; } = {};
    let symlinks: vfs.FileSet | undefined;
    // Marker positions

    // Split up the input file by line
    // Note: IE JS engine incorrectly handles consecutive delimiters here when using RegExp split, so
    // we have to string-based splitting instead and try to figure out the delimiting chars
    const lines = contents.split("\n");
    let i = 0;

    const markerPositions = new Map<string, Marker>();
    const markers: Marker[] = [];
    const ranges: Range[] = [];

    // Stuff related to the subfile we're parsing
    let currentFileContent: string | undefined;
    let currentFileName = fileName;
    let currentFileSymlinks: string[] | undefined;
    let currentFileOptions: { [s: string]: string; } = {};

    function nextFile() {
        if (currentFileContent === undefined) return;

        const file = parseFileContent(currentFileContent, currentFileName, markerPositions, markers, ranges);
        file.fileOptions = currentFileOptions;
        file.symlinks = currentFileSymlinks;

        // Store result file
        files.push(file);

        currentFileContent = undefined;
        currentFileOptions = {};
        currentFileName = fileName;
        currentFileSymlinks = undefined;
    }

    for (let line of lines) {
        i++;
        if (line.length > 0 && line.charAt(line.length - 1) === "\r") {
            line = line.substr(0, line.length - 1);
        }

        if (line.substr(0, 4) === "////") {
            const text = line.substr(4);
            currentFileContent = currentFileContent === undefined ? text : currentFileContent + "\n" + text;
        }
        else if (line.substr(0, 3) === "///" && currentFileContent !== undefined) {
            throw new Error("Three-slash line in the middle of four-slash region at line " + i);
        }
        else if (line.substr(0, 2) === "//") {
            const possiblySymlinks = Harness.TestCaseParser.parseSymlinkFromTest(line, symlinks);
            if (possiblySymlinks) {
                symlinks = possiblySymlinks;
            }
            else {
                // Comment line, check for global/file @options and record them
                const match = optionRegex.exec(line.substr(2));
                if (match) {
                    const key = match[1].toLowerCase();
                    const value = match[2];
                    if (!ts.contains(fileMetadataNames, key)) {
                        // Check if the match is already existed in the global options
                        if (globalOptions[key] !== undefined) {
                            throw new Error(`Global option '${key}' already exists`);
                        }
                        globalOptions[key] = value;
                    }
                    else {
                        switch (key) {
                            case MetadataOptionNames.fileName:
                                // Found an @FileName directive, if this is not the first then create a new subfile
                                nextFile();
                                currentFileName = ts.isRootedDiskPath(value) ? value : basePath + "/" + value;
                                currentFileOptions[key] = value;
                                break;
                            case MetadataOptionNames.symlink:
                                currentFileSymlinks = ts.append(currentFileSymlinks, value);
                                break;
                            default:
                                // Add other fileMetadata flag
                                currentFileOptions[key] = value;
                        }
                    }
                }
            }
        }
        // Previously blank lines between fourslash content caused it to be considered as 2 files,
        // Remove this behavior since it just causes errors now
        else if (line !== "") {
            // Code line, terminate current subfile if there is one
            nextFile();
        }
    }

    return {
        markerPositions,
        markers,
        globalOptions,
        files,
        symlinks,
        ranges,
    };
}

function isConfig(file: FourSlashFile): boolean {
    return Harness.getConfigNameFromFileName(file.fileName) !== undefined;
}

const enum State {
    none,
    inSlashStarMarker,
    inObjectMarker,
}

function reportError(fileName: string, line: number, col: number, message: string): never {
    const errorMessage = fileName + "(" + line + "," + col + "): " + message;
    throw new Error(errorMessage);
}

function recordObjectMarker(fileName: string, location: LocationInformation, text: string, markerMap: Map<string, Marker>, markers: Marker[]): Marker | undefined {
    let markerValue;
    try {
        // Attempt to parse the marker value as JSON
        markerValue = JSON.parse("{ " + text + " }") as { name?: unknown; };
    }
    catch (e) {
        reportError(fileName, location.sourceLine, location.sourceColumn, "Unable to parse marker text " + e.message);
    }

    if (markerValue === undefined) {
        reportError(fileName, location.sourceLine, location.sourceColumn, "Object markers can not be empty");
    }

    const marker: Marker = {
        fileName,
        position: location.position,
        data: markerValue,
    };

    // Object markers can be anonymous
    if (typeof markerValue.name === "string") {
        markerMap.set(markerValue.name, marker);
    }

    markers.push(marker);

    return marker;
}

function recordMarker(fileName: string, location: LocationInformation, name: string, markerMap: Map<string, Marker>, markers: Marker[]): Marker | undefined {
    const marker: Marker = {
        fileName,
        position: location.position,
    };

    // Verify markers for uniqueness
    if (markerMap.has(name)) {
        const message = "Marker '" + name + "' is duplicated in the source file contents.";
        reportError(marker.fileName, location.sourceLine, location.sourceColumn, message);
    }
    else {
        markerMap.set(name, marker);
        markers.push(marker);
        return marker;
    }
}

function parseFileContent(content: string, fileName: string, markerMap: Map<string, Marker>, markers: Marker[], ranges: Range[]): FourSlashFile {
    content = chompLeadingSpace(content);

    // Any slash-star comment with a character not in this string is not a marker.
    const validMarkerChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz$1234567890_";

    /// The file content (minus metacharacters) so far
    let output = "";

    /// The current marker (or maybe multi-line comment?) we're parsing, possibly
    let openMarker: LocationInformation | undefined;

    /// A stack of the open range markers that are still unclosed
    const openRanges: RangeLocationInformation[] = [];

    /// A list of ranges we've collected so far */
    const localRanges: Range[] = [];

    /// The latest position of the start of an unflushed plain text area
    let lastNormalCharPosition = 0;

    /// The total number of metacharacters removed from the file (so far)
    let difference = 0;

    /// The fourslash file state object we are generating
    let state: State = State.none;

    /// Current position data
    let line = 1;
    let column = 1;

    const flush = (lastSafeCharIndex: number | undefined) => {
        output = output + content.substr(lastNormalCharPosition, lastSafeCharIndex === undefined ? undefined : lastSafeCharIndex - lastNormalCharPosition);
    };

    if (content.length > 0) {
        let previousChar = content.charAt(0);
        for (let i = 1; i < content.length; i++) {
            const currentChar = content.charAt(i);
            switch (state) {
                case State.none:
                    if (previousChar === "[" && currentChar === "|") {
                        // found a range start
                        openRanges.push({
                            position: (i - 1) - difference,
                            sourcePosition: i - 1,
                            sourceLine: line,
                            sourceColumn: column,
                        });
                        // copy all text up to marker position
                        flush(i - 1);
                        lastNormalCharPosition = i + 1;
                        difference += 2;
                    }
                    else if (previousChar === "|" && currentChar === "]") {
                        // found a range end
                        const rangeStart = openRanges.pop();
                        if (!rangeStart) {
                            reportError(fileName, line, column, "Found range end with no matching start.");
                        }

                        const range: Range = {
                            fileName,
                            pos: rangeStart.position,
                            end: (i - 1) - difference,
                            marker: rangeStart.marker,
                        };
                        localRanges.push(range);

                        // copy all text up to range marker position
                        flush(i - 1);
                        lastNormalCharPosition = i + 1;
                        difference += 2;
                    }
                    else if (previousChar === "/" && currentChar === "*") {
                        // found a possible marker start
                        state = State.inSlashStarMarker;
                        openMarker = {
                            position: (i - 1) - difference,
                            sourcePosition: i - 1,
                            sourceLine: line,
                            sourceColumn: column,
                        };
                    }
                    else if (previousChar === "{" && currentChar === "|") {
                        // found an object marker start
                        state = State.inObjectMarker;
                        openMarker = {
                            position: (i - 1) - difference,
                            sourcePosition: i - 1,
                            sourceLine: line,
                            sourceColumn: column,
                        };
                        flush(i - 1);
                    }
                    break;

                case State.inObjectMarker:
                    // Object markers are only ever terminated by |} and have no content restrictions
                    if (previousChar === "|" && currentChar === "}") {
                        // Record the marker
                        const objectMarkerNameText = content.substring(openMarker!.sourcePosition + 2, i - 1).trim();
                        const marker = recordObjectMarker(fileName, openMarker!, objectMarkerNameText, markerMap, markers);

                        if (openRanges.length > 0) {
                            openRanges[openRanges.length - 1].marker = marker;
                        }

                        // Set the current start to point to the end of the current marker to ignore its text
                        lastNormalCharPosition = i + 1;
                        difference += i + 1 - openMarker!.sourcePosition;

                        // Reset the state
                        openMarker = undefined;
                        state = State.none;
                    }
                    break;

                case State.inSlashStarMarker:
                    if (previousChar === "*" && currentChar === "/") {
                        // Record the marker
                        // start + 2 to ignore the */, -1 on the end to ignore the * (/ is next)
                        const markerNameText = content.substring(openMarker!.sourcePosition + 2, i - 1).trim();
                        const marker = recordMarker(fileName, openMarker!, markerNameText, markerMap, markers);

                        if (openRanges.length > 0) {
                            openRanges[openRanges.length - 1].marker = marker;
                        }

                        // Set the current start to point to the end of the current marker to ignore its text
                        flush(openMarker!.sourcePosition);
                        lastNormalCharPosition = i + 1;
                        difference += i + 1 - openMarker!.sourcePosition;

                        // Reset the state
                        openMarker = undefined;
                        state = State.none;
                    }
                    else if (!validMarkerChars.includes(currentChar)) {
                        if (currentChar === "*" && i < content.length - 1 && content.charAt(i + 1) === "/") {
                            // The marker is about to be closed, ignore the 'invalid' char
                        }
                        else {
                            // We've hit a non-valid marker character, so we were actually in a block comment
                            // Bail out the text we've gathered so far back into the output
                            flush(i);
                            lastNormalCharPosition = i;
                            openMarker = undefined;

                            state = State.none;
                        }
                    }
                    break;
            }

            if (currentChar === "\n" && previousChar === "\r") {
                // Ignore trailing \n after a \r
                continue;
            }
            else if (currentChar === "\n" || currentChar === "\r") {
                line++;
                column = 1;
                continue;
            }

            column++;
            previousChar = currentChar;
        }
    }

    // Add the remaining text
    flush(/*lastSafeCharIndex*/ undefined);

    if (openRanges.length > 0) {
        const openRange = openRanges[0];
        reportError(fileName, openRange.sourceLine, openRange.sourceColumn, "Unterminated range.");
    }

    if (openMarker) {
        reportError(fileName, openMarker.sourceLine, openMarker.sourceColumn, "Unterminated marker.");
    }

    // put ranges in the correct order
    localRanges.sort((a, b) => a.pos < b.pos ? -1 : a.pos === b.pos && a.end > b.end ? -1 : 1);
    localRanges.forEach(r => ranges.push(r));

    return {
        content: output,
        fileOptions: {},
        version: 0,
        fileName,
    };
}

function stringify(data: any, replacer?: (key: string, value: any) => any): string {
    return JSON.stringify(data, replacer, 2);
}

/** Collects an array of unique outputs. */
function unique<T>(inputs: readonly T[], getOutput: (t: T) => string): string[] {
    const set = new Map<string, true>();
    for (const input of inputs) {
        const out = getOutput(input);
        set.set(out, true);
    }
    return ts.arrayFrom(set.keys());
}

function toArray<T>(x: ArrayOrSingle<T>): readonly T[] {
    return ts.isArray(x) ? x : [x];
}

function makeWhitespaceVisible(text: string) {
    return text.replace(/ /g, "\u00B7").replace(/\r/g, "\u00B6").replace(/\n/g, "\u2193\n").replace(/\t/g, "\u2192   ");
}

function showTextDiff(expected: string, actual: string): string {
    // Only show whitespace if the difference is whitespace-only.
    if (differOnlyByWhitespace(expected, actual)) {
        expected = makeWhitespaceVisible(expected);
        actual = makeWhitespaceVisible(actual);
    }
    return displayExpectedAndActualString(expected, actual);
}

function differOnlyByWhitespace(a: string, b: string) {
    return stripWhitespace(a) === stripWhitespace(b);
}

function stripWhitespace(s: string): string {
    return s.replace(/\s/g, "");
}

function displayExpectedAndActualString(expected: string, actual: string, quoted = false) {
    const expectMsg = "\x1b[1mExpected\x1b[0m\x1b[31m";
    const actualMsg = "\x1b[1mActual\x1b[0m\x1b[31m";
    const expectedString = quoted ? '"' + expected + '"' : expected;
    const actualString = quoted ? '"' + actual + '"' : actual;
    return `\n${expectMsg}:\n${expectedString}\n\n${actualMsg}:\n${highlightDifferenceBetweenStrings(expected, actualString)}`;
}

function templateToRegExp(template: string) {
    return new RegExp(`^${ts.regExpEscape(template).replace(/\\\{\d+\\\}/g, ".*?")}$`);
}

function rangesOfDiffBetweenTwoStrings(source: string, target: string) {
    const ranges = [] as { start: number; length: number; }[];

    const addToIndex = (index: number) => {
        const closestIndex = ranges[ranges.length - 1];
        if (closestIndex) {
            const doesAddToIndex = closestIndex.start + closestIndex.length === index - 1;
            if (doesAddToIndex) {
                closestIndex.length = closestIndex.length + 1;
            }
            else {
                ranges.push({ start: index - 1, length: 1 });
            }
        }
        else {
            ranges.push({ start: index - 1, length: 1 });
        }
    };

    for (let index = 0; index < Math.max(source.length, target.length); index++) {
        const srcChar = source[index];
        const targetChar = target[index];
        if (srcChar !== targetChar) addToIndex(index);
    }

    return ranges;
}

// Adds an _ when the source string and the target string have a whitespace difference
function highlightDifferenceBetweenStrings(source: string, target: string) {
    const ranges = rangesOfDiffBetweenTwoStrings(source, target);
    let emTarget = target;
    ranges.forEach((range, index) => {
        const lhs = `\x1b[4m`;
        const rhs = `\x1b[0m\x1b[31m`;
        const additionalOffset = index * lhs.length + index * rhs.length;
        const before = emTarget.slice(0, range.start + 1 + additionalOffset);
        const between = emTarget.slice(
            range.start + 1 + additionalOffset,
            range.start + range.length + 1 + additionalOffset,
        );
        const after = emTarget.slice(range.start + range.length + 1 + additionalOffset, emTarget.length);
        emTarget = before + lhs + between + rhs + after;
    });
    return emTarget;
}

function codeFence(code: string, lang?: string) {
    return `\`\`\`${lang || ""}\n${code}\n\`\`\``;
}

function getRangeOfIdentifierTouchingPosition(content: string, position: number): ts.TextRange | undefined {
    const scanner = ts.createScanner(ts.ScriptTarget.Latest, /*skipTrivia*/ true, ts.LanguageVariant.Standard, content);
    while (scanner.scan() !== ts.SyntaxKind.EndOfFileToken) {
        const tokenFullStart = scanner.getTokenFullStart();
        if (scanner.getToken() === ts.SyntaxKind.Identifier && tokenFullStart <= position && scanner.getTokenEnd() >= position) {
            return { pos: tokenFullStart, end: scanner.getTokenEnd() };
        }
        if (tokenFullStart > position) {
            break;
        }
    }
}
