1import * as FourSlashInterface from "./_namespaces/FourSlashInterface"; 2import * as Harness from "./_namespaces/Harness"; 3import * as vfs from "./_namespaces/vfs"; 4import * as ts from "./_namespaces/ts"; 5import * as fakes from "./_namespaces/fakes"; 6import * as vpath from "./_namespaces/vpath"; 7import * as Utils from "./_namespaces/Utils"; 8 9import ArrayOrSingle = FourSlashInterface.ArrayOrSingle; 10 11export const enum FourSlashTestType { 12 Native, 13 Shims, 14 ShimsWithPreprocess, 15 Server, 16 OH 17} 18 19// Represents a parsed source file with metadata 20interface FourSlashFile { 21 // The contents of the file (with markers, etc stripped out) 22 content: string; 23 fileName: string; 24 symlinks?: string[]; 25 version: number; 26 // File-specific options (name/value pairs) 27 fileOptions: Harness.TestCaseParser.CompilerSettings; 28} 29 30// Represents a set of parsed source files and options 31interface FourSlashData { 32 // Global options (name/value pairs) 33 globalOptions: Harness.TestCaseParser.CompilerSettings; 34 35 files: FourSlashFile[]; 36 37 symlinks: vfs.FileSet | undefined; 38 39 // A mapping from marker names to name/position pairs 40 markerPositions: ts.ESMap<string, Marker>; 41 42 markers: Marker[]; 43 44 /** 45 * Inserted in source files by surrounding desired text 46 * in a range with `[|` and `|]`. For example, 47 * 48 * [|text in range|] 49 * 50 * is a range with `text in range` "selected". 51 */ 52 ranges: Range[]; 53 rangesByText?: ts.MultiMap<string, Range>; 54} 55 56export interface Marker { 57 fileName: string; 58 position: number; 59 data?: {}; 60} 61 62export interface Range extends ts.TextRange { 63 fileName: string; 64 marker?: Marker; 65} 66 67interface LocationInformation { 68 position: number; 69 sourcePosition: number; 70 sourceLine: number; 71 sourceColumn: number; 72} 73 74interface RangeLocationInformation extends LocationInformation { 75 marker?: Marker; 76} 77 78interface ImplementationLocationInformation extends ts.ImplementationLocation { 79 matched?: boolean; 80} 81 82export interface TextSpan { 83 start: number; 84 end: number; 85} 86 87// Name of testcase metadata including ts.CompilerOptions properties that will be used by globalOptions 88// To add additional option, add property into the testOptMetadataNames, refer the property in either globalMetadataNames or fileMetadataNames 89// Add cases into convertGlobalOptionsToCompilationsSettings function for the compiler to acknowledge such option from meta data 90const enum MetadataOptionNames { 91 baselineFile = "baselinefile", 92 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 93 fileName = "filename", 94 resolveReference = "resolvereference", // This flag is used to specify entry file for resolve file references. The flag is only allow once per test file 95 symlink = "symlink", 96} 97 98// List of allowed metadata names 99const fileMetadataNames = [MetadataOptionNames.fileName, MetadataOptionNames.emitThisFile, MetadataOptionNames.resolveReference, MetadataOptionNames.symlink]; 100 101function convertGlobalOptionsToCompilerOptions(globalOptions: Harness.TestCaseParser.CompilerSettings): ts.CompilerOptions { 102 const settings: ts.CompilerOptions = { target: ts.ScriptTarget.ES5 }; 103 Harness.Compiler.setCompilerOptionsFromHarnessSetting(globalOptions, settings); 104 return settings; 105} 106 107export class TestCancellationToken implements ts.HostCancellationToken { 108 // 0 - cancelled 109 // >0 - not cancelled 110 // <0 - not cancelled and value denotes number of isCancellationRequested after which token become cancelled 111 private static readonly notCanceled = -1; 112 private numberOfCallsBeforeCancellation = TestCancellationToken.notCanceled; 113 114 public isCancellationRequested(): boolean { 115 if (this.numberOfCallsBeforeCancellation < 0) { 116 return false; 117 } 118 119 if (this.numberOfCallsBeforeCancellation > 0) { 120 this.numberOfCallsBeforeCancellation--; 121 return false; 122 } 123 124 return true; 125 } 126 127 public setCancelled(numberOfCalls = 0): void { 128 ts.Debug.assert(numberOfCalls >= 0); 129 this.numberOfCallsBeforeCancellation = numberOfCalls; 130 } 131 132 public resetCancelled(): void { 133 this.numberOfCallsBeforeCancellation = TestCancellationToken.notCanceled; 134 } 135} 136 137export function verifyOperationIsCancelled(f: () => void) { 138 try { 139 f(); 140 } 141 catch (e) { 142 if (e instanceof ts.OperationCanceledException) { 143 return; 144 } 145 } 146 147 throw new Error("Operation should be cancelled"); 148} 149 150export function ignoreInterpolations(diagnostic: string | ts.DiagnosticMessage): FourSlashInterface.DiagnosticIgnoredInterpolations { 151 return { template: typeof diagnostic === "string" ? diagnostic : diagnostic.message }; 152} 153 154// This function creates IScriptSnapshot object for testing getPreProcessedFileInfo 155// Return object may lack some functionalities for other purposes. 156function createScriptSnapShot(sourceText: string): ts.IScriptSnapshot { 157 return ts.ScriptSnapshot.fromString(sourceText); 158} 159 160const enum CallHierarchyItemDirection { 161 Root, 162 Incoming, 163 Outgoing 164} 165 166export class TestState { 167 // Language service instance 168 private languageServiceAdapterHost: Harness.LanguageService.LanguageServiceAdapterHost; 169 private languageService: ts.LanguageService; 170 private cancellationToken: TestCancellationToken; 171 private assertTextConsistent: ((fileName: string) => void) | undefined; 172 173 // The current caret position in the active file 174 public currentCaretPosition = 0; 175 // The position of the end of the current selection, or -1 if nothing is selected 176 public selectionEnd = -1; 177 178 public lastKnownMarker: string | undefined; 179 180 // The file that's currently 'opened' 181 public activeFile!: FourSlashFile; 182 183 // Whether or not we should format on keystrokes 184 public enableFormatting = true; 185 186 public formatCodeSettings: ts.FormatCodeSettings; 187 188 private inputFiles = new ts.Map<string, string>(); // Map between inputFile's fileName and its content for easily looking up when resolving references 189 190 private static getDisplayPartsJson(displayParts: ts.SymbolDisplayPart[] | undefined) { 191 let result = ""; 192 ts.forEach(displayParts, part => { 193 if (result) { 194 result += ",\n "; 195 } 196 else { 197 result = "[\n "; 198 } 199 result += JSON.stringify(part); 200 }); 201 if (result) { 202 result += "\n]"; 203 } 204 205 return result; 206 } 207 208 // Add input file which has matched file name with the given reference-file path. 209 // This is necessary when resolveReference flag is specified 210 private addMatchedInputFile(referenceFilePath: string, extensions: readonly string[] | undefined) { 211 const inputFiles = this.inputFiles; 212 const languageServiceAdapterHost = this.languageServiceAdapterHost; 213 const didAdd = tryAdd(referenceFilePath); 214 if (extensions && !didAdd) { 215 ts.forEach(extensions, ext => tryAdd(referenceFilePath + ext)); 216 } 217 218 function tryAdd(path: string) { 219 const inputFile = inputFiles.get(path); 220 if (inputFile && !Harness.isDefaultLibraryFile(path)) { 221 languageServiceAdapterHost.addScript(path, inputFile, /*isRootFile*/ true); 222 return true; 223 } 224 } 225 } 226 227 private getLanguageServiceAdapter(testType: FourSlashTestType, cancellationToken: TestCancellationToken, compilationOptions: ts.CompilerOptions): Harness.LanguageService.LanguageServiceAdapter { 228 switch (testType) { 229 case FourSlashTestType.Native: 230 return new Harness.LanguageService.NativeLanguageServiceAdapter(cancellationToken, compilationOptions); 231 case FourSlashTestType.OH: 232 return new Harness.LanguageService.NativeLanguageServiceAdapter(cancellationToken, { ...compilationOptions, ...{ packageManagerType: "ohpm" } }); 233 case FourSlashTestType.Shims: 234 return new Harness.LanguageService.ShimLanguageServiceAdapter(/*preprocessToResolve*/ false, cancellationToken, compilationOptions); 235 case FourSlashTestType.ShimsWithPreprocess: 236 return new Harness.LanguageService.ShimLanguageServiceAdapter(/*preprocessToResolve*/ true, cancellationToken, compilationOptions); 237 case FourSlashTestType.Server: 238 return new Harness.LanguageService.ServerLanguageServiceAdapter(cancellationToken, compilationOptions); 239 default: 240 throw new Error("Unknown FourSlash test type: "); 241 } 242 } 243 244 constructor(private originalInputFileName: string, private basePath: string, private testType: FourSlashTestType, public testData: FourSlashData) { 245 // Create a new Services Adapter 246 this.cancellationToken = new TestCancellationToken(); 247 let compilationOptions = convertGlobalOptionsToCompilerOptions(this.testData.globalOptions); 248 compilationOptions.skipDefaultLibCheck = true; 249 250 // Initialize the language service with all the scripts 251 let startResolveFileRef: FourSlashFile | undefined; 252 253 let configFileName: string | undefined; 254 for (const file of testData.files) { 255 // Create map between fileName and its content for easily looking up when resolveReference flag is specified 256 this.inputFiles.set(file.fileName, file.content); 257 if (isConfig(file)) { 258 const configJson = ts.parseConfigFileTextToJson(file.fileName, file.content); 259 if (configJson.config === undefined) { 260 throw new Error(`Failed to parse test ${file.fileName}: ${configJson.error!.messageText}`); 261 } 262 263 // Extend our existing compiler options so that we can also support tsconfig only options 264 if (configJson.config.compilerOptions) { 265 const baseDirectory = ts.normalizePath(ts.getDirectoryPath(file.fileName)); 266 const tsConfig = ts.convertCompilerOptionsFromJson(configJson.config.compilerOptions, baseDirectory, file.fileName); 267 268 if (!tsConfig.errors || !tsConfig.errors.length) { 269 compilationOptions = ts.extend(tsConfig.options, compilationOptions); 270 } 271 } 272 configFileName = file.fileName; 273 } 274 275 if (!startResolveFileRef && file.fileOptions[MetadataOptionNames.resolveReference] === "true") { 276 startResolveFileRef = file; 277 } 278 else if (startResolveFileRef) { 279 // If entry point for resolving file references is already specified, report duplication error 280 throw new Error("There exists a Fourslash file which has resolveReference flag specified; remove duplicated resolveReference flag"); 281 } 282 } 283 284 let configParseResult: ts.ParsedCommandLine | undefined; 285 if (configFileName) { 286 const baseDir = ts.normalizePath(ts.getDirectoryPath(configFileName)); 287 const files: vfs.FileSet = { [baseDir]: {} }; 288 this.inputFiles.forEach((data, path) => { 289 const scriptInfo = new Harness.LanguageService.ScriptInfo(path, undefined!, /*isRootFile*/ false); // TODO: GH#18217 290 files[path] = new vfs.File(data, { meta: { scriptInfo } }); 291 }); 292 const fs = new vfs.FileSystem(/*ignoreCase*/ true, { cwd: baseDir, files }); 293 const host = new fakes.ParseConfigHost(fs); 294 const jsonSourceFile = ts.parseJsonText(configFileName, this.inputFiles.get(configFileName)!); 295 configParseResult = ts.parseJsonSourceFileConfigFileContent(jsonSourceFile, host, baseDir, compilationOptions, configFileName); 296 compilationOptions = configParseResult.options; 297 } 298 299 if (compilationOptions.typeRoots) { 300 compilationOptions.typeRoots = compilationOptions.typeRoots.map(p => ts.getNormalizedAbsolutePath(p, this.basePath)); 301 } 302 303 const languageServiceAdapter = this.getLanguageServiceAdapter(testType, this.cancellationToken, compilationOptions); 304 this.languageServiceAdapterHost = languageServiceAdapter.getHost(); 305 this.languageService = memoWrap(languageServiceAdapter.getLanguageService(), this); // Wrap the LS to cache some expensive operations certain tests call repeatedly 306 if (this.testType === FourSlashTestType.Server) { 307 this.assertTextConsistent = fileName => (languageServiceAdapter as Harness.LanguageService.ServerLanguageServiceAdapter).assertTextConsistent(fileName); 308 } 309 310 if (startResolveFileRef) { 311 // Add the entry-point file itself into the languageServiceShimHost 312 this.languageServiceAdapterHost.addScript(startResolveFileRef.fileName, startResolveFileRef.content, /*isRootFile*/ true); 313 314 const resolvedResult = languageServiceAdapter.getPreProcessedFileInfo(startResolveFileRef.fileName, startResolveFileRef.content); 315 const referencedFiles: ts.FileReference[] = resolvedResult.referencedFiles; 316 const importedFiles: ts.FileReference[] = resolvedResult.importedFiles; 317 318 // Add triple reference files into language-service host 319 ts.forEach(referencedFiles, referenceFile => { 320 // Fourslash insert tests/cases/fourslash into inputFile.unitName so we will properly append the same base directory to refFile path 321 const referenceFilePath = this.basePath + "/" + referenceFile.fileName; 322 this.addMatchedInputFile(referenceFilePath, /* extensions */ undefined); 323 }); 324 325 const exts = ts.flatten(ts.getSupportedExtensions(compilationOptions)); 326 // Add import files into language-service host 327 ts.forEach(importedFiles, importedFile => { 328 // Fourslash insert tests/cases/fourslash into inputFile.unitName and import statement doesn't require ".ts" 329 // so convert them before making appropriate comparison 330 const importedFilePath = this.basePath + "/" + importedFile.fileName; 331 this.addMatchedInputFile(importedFilePath, exts); 332 }); 333 334 // Check if no-default-lib flag is false and if so add default library 335 if (!resolvedResult.isLibFile) { 336 this.languageServiceAdapterHost.addScript(Harness.Compiler.defaultLibFileName, 337 Harness.Compiler.getDefaultLibrarySourceFile()!.text, /*isRootFile*/ false); 338 339 compilationOptions.lib?.forEach(fileName => { 340 const libFile = Harness.Compiler.getDefaultLibrarySourceFile(fileName); 341 ts.Debug.assertIsDefined(libFile, `Could not find lib file '${fileName}'`); 342 if (libFile) { 343 this.languageServiceAdapterHost.addScript(fileName, libFile.text, /*isRootFile*/ false); 344 } 345 }); 346 } 347 } 348 else { 349 // resolveReference file-option is not specified then do not resolve any files and include all inputFiles 350 this.inputFiles.forEach((file, fileName) => { 351 if (!Harness.isDefaultLibraryFile(fileName)) { 352 // all files if config file not specified, otherwise root files from the config and typings cache files are root files 353 const isRootFile = !configParseResult || 354 ts.contains(configParseResult.fileNames, fileName) || 355 (ts.isDeclarationFileName(fileName) && ts.containsPath("/Library/Caches/typescript", fileName)); 356 this.languageServiceAdapterHost.addScript(fileName, file, isRootFile); 357 } 358 }); 359 360 if (!compilationOptions.noLib) { 361 const seen = new Set<string>(); 362 const addSourceFile = (fileName: string) => { 363 if (seen.has(fileName)) return; 364 seen.add(fileName); 365 const libFile = Harness.Compiler.getDefaultLibrarySourceFile(fileName); 366 ts.Debug.assertIsDefined(libFile, `Could not find lib file '${fileName}'`); 367 this.languageServiceAdapterHost.addScript(fileName, libFile.text, /*isRootFile*/ false); 368 if (!ts.some(libFile.libReferenceDirectives)) return; 369 for (const directive of libFile.libReferenceDirectives) { 370 addSourceFile(`lib.${directive.fileName}.d.ts`); 371 } 372 }; 373 374 addSourceFile(Harness.Compiler.defaultLibFileName); 375 compilationOptions.lib?.forEach(addSourceFile); 376 } 377 378 compilationOptions.ets?.libs.forEach(fileName => { 379 const content = ts.sys.readFile(fileName); 380 if (content) { 381 this.languageServiceAdapterHost.addScript(fileName, content, /*isRootFile*/ false); 382 } 383 }); 384 } 385 386 for (const file of testData.files) { 387 ts.forEach(file.symlinks, link => { 388 this.languageServiceAdapterHost.vfs.mkdirpSync(vpath.dirname(link)); 389 this.languageServiceAdapterHost.vfs.symlinkSync(file.fileName, link); 390 }); 391 } 392 393 if (testData.symlinks) { 394 this.languageServiceAdapterHost.vfs.apply(testData.symlinks); 395 } 396 397 this.formatCodeSettings = ts.testFormatSettings; 398 399 // Open the first file by default 400 this.openFile(0); 401 402 function memoWrap(ls: ts.LanguageService, target: TestState): ts.LanguageService { 403 const cacheableMembers: (keyof typeof ls)[] = [ 404 "getCompletionEntryDetails", 405 "getCompletionEntrySymbol", 406 "getQuickInfoAtPosition", 407 "getReferencesAtPosition", 408 "getDocumentHighlights", 409 ]; 410 const proxy = {} as ts.LanguageService; 411 const keys = ts.getAllKeys(ls); 412 for (const k of keys) { 413 const key = k as keyof typeof ls; 414 if (cacheableMembers.indexOf(key) === -1) { 415 proxy[key] = (...args: any[]) => (ls[key] as Function)(...args); 416 continue; 417 } 418 const memo = Utils.memoize( 419 (_version: number, _active: string, _caret: number, _selectEnd: number, _marker: string | undefined, ...args: any[]) => (ls[key] as Function)(...args), 420 (...args) => args.map(a => a && typeof a === "object" ? JSON.stringify(a) : a).join("|,|") 421 ); 422 proxy[key] = (...args: any[]) => memo( 423 target.languageServiceAdapterHost.getScriptInfo(target.activeFile.fileName)!.version, 424 target.activeFile.fileName, 425 target.currentCaretPosition, 426 target.selectionEnd, 427 target.lastKnownMarker, 428 ...args 429 ); 430 } 431 return proxy; 432 } 433 } 434 435 private getFileContent(fileName: string): string { 436 return ts.Debug.checkDefined(this.tryGetFileContent(fileName)); 437 } 438 private tryGetFileContent(fileName: string): string | undefined { 439 const script = this.languageServiceAdapterHost.getScriptInfo(fileName); 440 return script && script.content; 441 } 442 443 // Entry points from fourslash.ts 444 public goToMarker(name: string | Marker = "") { 445 const marker = ts.isString(name) ? this.getMarkerByName(name) : name; 446 if (this.activeFile.fileName !== marker.fileName) { 447 this.openFile(marker.fileName); 448 } 449 450 const content = this.getFileContent(marker.fileName); 451 if (marker.position === -1 || marker.position > content.length) { 452 throw new Error(`Marker "${name}" has been invalidated by unrecoverable edits to the file.`); 453 } 454 const mName = ts.isString(name) ? name : this.markerName(marker); 455 this.lastKnownMarker = mName; 456 this.goToPosition(marker.position); 457 } 458 459 public goToEachMarker(markers: readonly Marker[], action: (marker: Marker, index: number) => void) { 460 assert(markers.length); 461 for (let i = 0; i < markers.length; i++) { 462 this.goToMarker(markers[i]); 463 action(markers[i], i); 464 } 465 } 466 467 public goToEachRange(action: (range: Range) => void) { 468 const ranges = this.getRanges(); 469 assert(ranges.length); 470 for (const range of ranges) { 471 this.selectRange(range); 472 action(range); 473 } 474 } 475 476 public markerName(m: Marker): string { 477 return ts.forEachEntry(this.testData.markerPositions, (marker, name) => { 478 if (marker === m) { 479 return name; 480 } 481 })!; 482 } 483 484 public goToPosition(positionOrLineAndCharacter: number | ts.LineAndCharacter) { 485 const pos = typeof positionOrLineAndCharacter === "number" 486 ? positionOrLineAndCharacter 487 : this.languageServiceAdapterHost.lineAndCharacterToPosition(this.activeFile.fileName, positionOrLineAndCharacter); 488 this.currentCaretPosition = pos; 489 this.selectionEnd = -1; 490 } 491 492 public select(startMarker: string, endMarker: string) { 493 const start = this.getMarkerByName(startMarker), end = this.getMarkerByName(endMarker); 494 ts.Debug.assert(start.fileName === end.fileName); 495 if (this.activeFile.fileName !== start.fileName) { 496 this.openFile(start.fileName); 497 } 498 this.goToPosition(start.position); 499 this.selectionEnd = end.position; 500 } 501 502 public selectAllInFile(fileName: string) { 503 this.openFile(fileName); 504 this.goToPosition(0); 505 this.selectionEnd = this.activeFile.content.length; 506 } 507 508 public selectRange(range: Range): void { 509 this.goToRangeStart(range); 510 this.selectionEnd = range.end; 511 } 512 513 public selectLine(index: number) { 514 const lineStart = this.languageServiceAdapterHost.lineAndCharacterToPosition(this.activeFile.fileName, { line: index, character: 0 }); 515 const lineEnd = lineStart + this.getLineContent(index).length; 516 this.selectRange({ fileName: this.activeFile.fileName, pos: lineStart, end: lineEnd }); 517 } 518 519 public moveCaretRight(count = 1) { 520 this.currentCaretPosition += count; 521 this.currentCaretPosition = Math.min(this.currentCaretPosition, this.getFileContent(this.activeFile.fileName).length); 522 this.selectionEnd = -1; 523 } 524 525 // Opens a file given its 0-based index or fileName 526 public openFile(indexOrName: number | string, content?: string, scriptKindName?: string): void { 527 const fileToOpen: FourSlashFile = this.findFile(indexOrName); 528 fileToOpen.fileName = ts.normalizeSlashes(fileToOpen.fileName); 529 this.activeFile = fileToOpen; 530 // Let the host know that this file is now open 531 this.languageServiceAdapterHost.openFile(fileToOpen.fileName, content, scriptKindName); 532 } 533 534 public verifyErrorExistsBetweenMarkers(startMarkerName: string, endMarkerName: string, shouldExist: boolean) { 535 const startMarker = this.getMarkerByName(startMarkerName); 536 const endMarker = this.getMarkerByName(endMarkerName); 537 const predicate = (errorMinChar: number, errorLimChar: number, startPos: number, endPos: number) => 538 ((errorMinChar === startPos) && (errorLimChar === endPos)) ? true : false; 539 540 const exists = this.anyErrorInRange(predicate, startMarker, endMarker); 541 542 if (exists !== shouldExist) { 543 this.printErrorLog(shouldExist, this.getAllDiagnostics()); 544 throw new Error(`${shouldExist ? "Expected" : "Did not expect"} failure between markers: '${startMarkerName}', '${endMarkerName}'`); 545 } 546 } 547 548 public verifyOrganizeImports(newContent: string, mode?: ts.OrganizeImportsMode) { 549 const changes = this.languageService.organizeImports({ fileName: this.activeFile.fileName, type: "file", mode }, this.formatCodeSettings, ts.emptyOptions); 550 this.applyChanges(changes); 551 this.verifyFileContent(this.activeFile.fileName, newContent); 552 } 553 554 private raiseError(message: string): never { 555 throw new Error(this.messageAtLastKnownMarker(message)); 556 } 557 558 private messageAtLastKnownMarker(message: string) { 559 const locationDescription = this.lastKnownMarker !== undefined ? this.lastKnownMarker : this.getLineColStringAtPosition(this.currentCaretPosition); 560 return `At marker '${locationDescription}': ${message}`; 561 } 562 563 private assertionMessageAtLastKnownMarker(msg: string) { 564 return "\nMarker: " + this.lastKnownMarker + "\nChecking: " + msg + "\n\n"; 565 } 566 567 private getDiagnostics(fileName: string, includeSuggestions = false): ts.Diagnostic[] { 568 return [ 569 ...this.languageService.getSyntacticDiagnostics(fileName), 570 ...this.languageService.getSemanticDiagnostics(fileName), 571 ...(includeSuggestions ? this.languageService.getSuggestionDiagnostics(fileName) : ts.emptyArray), 572 ]; 573 } 574 575 private getAllDiagnostics(): readonly ts.Diagnostic[] { 576 return ts.flatMap(this.languageServiceAdapterHost.getFilenames(), fileName => { 577 if (!ts.isAnySupportedFileExtension(fileName)) { 578 return []; 579 } 580 581 const baseName = ts.getBaseFileName(fileName); 582 if (baseName === "package.json" || baseName === "oh-package.json5" || baseName === "tsconfig.json" || baseName === "jsconfig.json") { 583 return []; 584 } 585 return this.getDiagnostics(fileName); 586 }); 587 } 588 589 public verifyErrorExistsAfterMarker(markerName: string, shouldExist: boolean, after: boolean) { 590 const marker: Marker = this.getMarkerByName(markerName); 591 let predicate: (errorMinChar: number, errorLimChar: number, startPos: number, endPos: number) => boolean; 592 593 if (after) { 594 predicate = (errorMinChar: number, errorLimChar: number, startPos: number) => 595 ((errorMinChar >= startPos) && (errorLimChar >= startPos)) ? true : false; 596 } 597 else { 598 predicate = (errorMinChar: number, errorLimChar: number, startPos: number) => 599 ((errorMinChar <= startPos) && (errorLimChar <= startPos)) ? true : false; 600 } 601 602 const exists = this.anyErrorInRange(predicate, marker); 603 const diagnostics = this.getAllDiagnostics(); 604 605 if (exists !== shouldExist) { 606 this.printErrorLog(shouldExist, diagnostics); 607 throw new Error(`${shouldExist ? "Expected" : "Did not expect"} failure at marker '${markerName}'`); 608 } 609 } 610 611 private anyErrorInRange(predicate: (errorMinChar: number, errorLimChar: number, startPos: number, endPos: number | undefined) => boolean, startMarker: Marker, endMarker?: Marker): boolean { 612 return this.getDiagnostics(startMarker.fileName).some(({ start, length }) => 613 predicate(start!, start! + length!, startMarker.position, endMarker === undefined ? undefined : endMarker.position)); // TODO: GH#18217 614 } 615 616 private printErrorLog(expectErrors: boolean, errors: readonly ts.Diagnostic[]): void { 617 if (expectErrors) { 618 Harness.IO.log("Expected error not found. Error list is:"); 619 } 620 else { 621 Harness.IO.log("Unexpected error(s) found. Error list is:"); 622 } 623 624 for (const { start, length, messageText, file } of errors) { 625 Harness.IO.log(" " + this.formatRange(file, start!, length!) + // TODO: GH#18217 626 ", message: " + ts.flattenDiagnosticMessageText(messageText, Harness.IO.newLine()) + "\n"); 627 } 628 } 629 630 private formatRange(file: ts.SourceFile | undefined, start: number, length: number) { 631 if (file) { 632 return `from: ${this.formatLineAndCharacterOfPosition(file, start)}, to: ${this.formatLineAndCharacterOfPosition(file, start + length)}`; 633 } 634 return "global"; 635 } 636 637 private formatLineAndCharacterOfPosition(file: ts.SourceFile, pos: number) { 638 if (file) { 639 const { line, character } = ts.getLineAndCharacterOfPosition(file, pos); 640 return `${line}:${character}`; 641 } 642 return "global"; 643 } 644 645 private formatPosition(file: ts.SourceFile, pos: number) { 646 if (file) { 647 return file.fileName + "@" + pos; 648 } 649 return "global"; 650 } 651 652 public verifyNoErrors() { 653 ts.forEachKey(this.inputFiles, fileName => { 654 if (!ts.isAnySupportedFileExtension(fileName) 655 || Harness.getConfigNameFromFileName(fileName) 656 // Can't get a Program in Server tests 657 || this.testType !== FourSlashTestType.Server && !ts.getAllowJSCompilerOption(this.getProgram().getCompilerOptions()) && !ts.resolutionExtensionIsTSOrJson(ts.extensionFromPath(fileName)) 658 || ts.getBaseFileName(fileName) === "package.json") return; 659 const errors = this.getDiagnostics(fileName).filter(e => e.category !== ts.DiagnosticCategory.Suggestion); 660 if (errors.length) { 661 this.printErrorLog(/*expectErrors*/ false, errors); 662 const error = errors[0]; 663 const message = typeof error.messageText === "string" ? error.messageText : error.messageText.messageText; 664 this.raiseError(`Found an error: ${this.formatPosition(error.file!, error.start!)}: ${message}`); 665 } 666 }); 667 } 668 669 public verifyErrorExistsAtRange(range: Range, code: number, expectedMessage?: string) { 670 const span = ts.createTextSpanFromRange(range); 671 const hasMatchingError = ts.some( 672 this.getDiagnostics(range.fileName), 673 ({ code, messageText, start, length }) => 674 code === code && 675 (!expectedMessage || expectedMessage === messageText) && 676 ts.isNumber(start) && ts.isNumber(length) && 677 ts.textSpansEqual(span, { start, length })); 678 679 if (!hasMatchingError) { 680 this.raiseError(`No error with code ${code} found at provided range.`); 681 } 682 } 683 684 public verifyNumberOfErrorsInCurrentFile(expected: number) { 685 const errors = this.getDiagnostics(this.activeFile.fileName); 686 const actual = errors.length; 687 688 if (actual !== expected) { 689 this.printErrorLog(/*expectErrors*/ false, errors); 690 const errorMsg = "Actual number of errors (" + actual + ") does not match expected number (" + expected + ")"; 691 Harness.IO.log(errorMsg); 692 this.raiseError(errorMsg); 693 } 694 } 695 696 public verifyEval(expr: string, value: any) { 697 const emit = this.languageService.getEmitOutput(this.activeFile.fileName); 698 if (emit.outputFiles.length !== 1) { 699 throw new Error("Expected exactly one output from emit of " + this.activeFile.fileName); 700 } 701 702 const evaluation = new Function(`${emit.outputFiles[0].text};\r\nreturn (${expr});`)(); // eslint-disable-line no-new-func 703 if (evaluation !== value) { 704 this.raiseError(`Expected evaluation of expression "${expr}" to equal "${value}", but got "${evaluation}"`); 705 } 706 } 707 708 public verifyGoToDefinitionIs(endMarker: ArrayOrSingle<string>) { 709 this.verifyGoToXWorker(/*startMarker*/ undefined, toArray(endMarker), () => this.getGoToDefinition()); 710 } 711 712 public verifyGoToDefinition(arg0: any, endMarkerNames?: ArrayOrSingle<string | { marker: string, unverified?: boolean }> | { file: string, unverified?: boolean }) { 713 this.verifyGoToX(arg0, endMarkerNames, () => this.getGoToDefinitionAndBoundSpan()); 714 } 715 716 public verifyGoToSourceDefinition(startMarkerNames: ArrayOrSingle<string>, end?: ArrayOrSingle<string | { marker: string, unverified?: boolean }> | { file: string, unverified?: boolean }) { 717 if (this.testType !== FourSlashTestType.Server) { 718 this.raiseError("goToSourceDefinition may only be used in fourslash/server tests."); 719 } 720 this.verifyGoToX(startMarkerNames, end, () => (this.languageService as ts.server.SessionClient).getSourceDefinitionAndBoundSpan(this.activeFile.fileName, this.currentCaretPosition)!); 721 } 722 723 private getGoToDefinition(): readonly ts.DefinitionInfo[] { 724 return this.languageService.getDefinitionAtPosition(this.activeFile.fileName, this.currentCaretPosition)!; 725 } 726 727 private getGoToDefinitionAndBoundSpan(): ts.DefinitionInfoAndBoundSpan { 728 return this.languageService.getDefinitionAndBoundSpan(this.activeFile.fileName, this.currentCaretPosition)!; 729 } 730 731 public verifyGoToType(arg0: any, endMarkerNames?: ArrayOrSingle<string>) { 732 this.verifyGoToX(arg0, endMarkerNames, () => 733 this.languageService.getTypeDefinitionAtPosition(this.activeFile.fileName, this.currentCaretPosition)); 734 } 735 736 private verifyGoToX(arg0: any, endMarkerNames: ArrayOrSingle<string | { marker: string, unverified?: boolean }> | { file: string, unverified?: boolean } | undefined, getDefs: () => readonly ts.DefinitionInfo[] | ts.DefinitionInfoAndBoundSpan | undefined) { 737 if (endMarkerNames) { 738 this.verifyGoToXPlain(arg0, endMarkerNames, getDefs); 739 } 740 else if (ts.isArray(arg0)) { 741 const pairs = arg0 as readonly [ArrayOrSingle<string>, ArrayOrSingle<string>][]; 742 for (const [start, end] of pairs) { 743 this.verifyGoToXPlain(start, end, getDefs); 744 } 745 } 746 else { 747 const obj: { [startMarkerName: string]: ArrayOrSingle<string> } = arg0; 748 for (const startMarkerName in obj) { 749 if (ts.hasProperty(obj, startMarkerName)) { 750 this.verifyGoToXPlain(startMarkerName, obj[startMarkerName], getDefs); 751 } 752 } 753 } 754 } 755 756 private verifyGoToXPlain(startMarkerNames: ArrayOrSingle<string>, endMarkerNames: ArrayOrSingle<string | { marker: string, unverified?: boolean }> | { file: string, unverified?: boolean }, getDefs: () => readonly ts.DefinitionInfo[] | ts.DefinitionInfoAndBoundSpan | undefined) { 757 for (const start of toArray(startMarkerNames)) { 758 this.verifyGoToXSingle(start, endMarkerNames, getDefs); 759 } 760 } 761 762 public verifyGoToDefinitionForMarkers(markerNames: string[]) { 763 for (const markerName of markerNames) { 764 this.verifyGoToXSingle(`${markerName}Reference`, `${markerName}Definition`, () => this.getGoToDefinition()); 765 } 766 } 767 768 private verifyGoToXSingle(startMarkerName: string, endMarkerNames: ArrayOrSingle<string | { marker: string, unverified?: boolean }> | { file: string, unverified?: boolean }, getDefs: () => readonly ts.DefinitionInfo[] | ts.DefinitionInfoAndBoundSpan | undefined) { 769 this.goToMarker(startMarkerName); 770 this.verifyGoToXWorker(startMarkerName, toArray(endMarkerNames), getDefs, startMarkerName); 771 } 772 773 private verifyGoToXWorker(startMarker: string | undefined, endMarkers: readonly (string | { marker?: string, file?: string, unverified?: boolean })[], getDefs: () => readonly ts.DefinitionInfo[] | ts.DefinitionInfoAndBoundSpan | undefined, startMarkerName?: string) { 774 const defs = getDefs(); 775 let definitions: readonly ts.DefinitionInfo[]; 776 let testName: string; 777 778 if (!defs || ts.isArray(defs)) { 779 definitions = defs as ts.DefinitionInfo[] || []; 780 testName = "goToDefinitions"; 781 } 782 else { 783 this.verifyDefinitionTextSpan(defs, startMarkerName!); 784 785 definitions = defs.definitions!; // TODO: GH#18217 786 testName = "goToDefinitionsAndBoundSpan"; 787 } 788 789 if (endMarkers.length !== definitions.length) { 790 const markers = definitions.map(d => ({ text: "HERE", fileName: d.fileName, position: d.textSpan.start })); 791 const actual = this.renderMarkers(markers); 792 this.raiseError(`${testName} failed - expected to find ${endMarkers.length} definitions but got ${definitions.length}\n\n${actual}`); 793 } 794 795 ts.zipWith(endMarkers, definitions, (endMarkerOrFileResult, definition, i) => { 796 const markerName = typeof endMarkerOrFileResult === "string" ? endMarkerOrFileResult : endMarkerOrFileResult.marker; 797 const marker = markerName !== undefined ? this.getMarkerByName(markerName) : undefined; 798 const expectedFileName = marker?.fileName || typeof endMarkerOrFileResult !== "string" && endMarkerOrFileResult.file; 799 ts.Debug.assert(typeof expectedFileName === "string"); 800 const expectedPosition = marker?.position || 0; 801 if (ts.comparePaths(expectedFileName, definition.fileName, /*ignoreCase*/ true) !== ts.Comparison.EqualTo || expectedPosition !== definition.textSpan.start) { 802 const markers = [{ text: "EXPECTED", fileName: expectedFileName, position: expectedPosition }, { text: "ACTUAL", fileName: definition.fileName, position: definition.textSpan.start }]; 803 const text = this.renderMarkers(markers); 804 this.raiseError(`${testName} failed for definition ${markerName || expectedFileName} (${i}): expected ${expectedFileName} at ${expectedPosition}, got ${definition.fileName} at ${definition.textSpan.start}\n\n${text}\n`); 805 } 806 if (definition.unverified && (typeof endMarkerOrFileResult === "string" || !endMarkerOrFileResult.unverified)) { 807 const isFileResult = typeof endMarkerOrFileResult !== "string" && !!endMarkerOrFileResult.file; 808 this.raiseError( 809 `${testName} failed for definition ${markerName || expectedFileName} (${i}): The actual definition was an \`unverified\` result. Use:\n\n` + 810 ` verify.goToDefinition(${startMarker === undefined ? "startMarker" : `"${startMarker}"`}, { ${isFileResult ? `file: "${expectedFileName}"` : `marker: "${markerName}"`}, unverified: true })\n\n` + 811 `if this is expected.` 812 ); 813 } 814 }); 815 } 816 817 private renderMarkers(markers: { text: string, fileName: string, position: number }[]) { 818 const filesToDisplay = ts.deduplicate(markers.map(m => m.fileName), ts.equateValues); 819 return filesToDisplay.map(fileName => { 820 const markersToRender = markers.filter(m => m.fileName === fileName).sort((a, b) => b.position - a.position); 821 let fileContent = this.tryGetFileContent(fileName) || ""; 822 for (const marker of markersToRender) { 823 fileContent = fileContent.slice(0, marker.position) + `\x1b[1;4m/*${marker.text}*/\x1b[0;31m` + fileContent.slice(marker.position); 824 } 825 return `// @Filename: ${fileName}\n${fileContent}`; 826 }).join("\n\n"); 827 } 828 829 private verifyDefinitionTextSpan(defs: ts.DefinitionInfoAndBoundSpan, startMarkerName: string) { 830 const range = this.testData.ranges.find(range => this.markerName(range.marker!) === startMarkerName); 831 832 if (!range && !defs.textSpan) { 833 return; 834 } 835 836 if (!range) { 837 const marker = this.getMarkerByName(startMarkerName); 838 const startFile = marker.fileName; 839 const fileContent = this.getFileContent(startFile); 840 const spanContent = fileContent.slice(defs.textSpan.start, ts.textSpanEnd(defs.textSpan)); 841 const spanContentWithMarker = spanContent.slice(0, marker.position - defs.textSpan.start) + `/*${startMarkerName}*/` + spanContent.slice(marker.position - defs.textSpan.start); 842 const suggestedFileContent = (fileContent.slice(0, defs.textSpan.start) + `\x1b[1;4m[|${spanContentWithMarker}|]\x1b[0;31m` + fileContent.slice(ts.textSpanEnd(defs.textSpan))) 843 .split(/\r?\n/).map(line => " ".repeat(6) + line).join(ts.sys.newLine); 844 this.raiseError(`goToDefinitionsAndBoundSpan failed. Found a starting TextSpan around '${spanContent}' in '${startFile}' (at position ${defs.textSpan.start}). ` 845 + `If this is the correct input span, put a fourslash range around it: \n\n${suggestedFileContent}\n`); 846 } 847 else { 848 this.assertTextSpanEqualsRange(defs.textSpan, range, "goToDefinitionsAndBoundSpan failed"); 849 } 850 } 851 852 public verifyGetEmitOutputForCurrentFile(expected: string): void { 853 const emit = this.languageService.getEmitOutput(this.activeFile.fileName); 854 if (emit.outputFiles.length !== 1) { 855 throw new Error("Expected exactly one output from emit of " + this.activeFile.fileName); 856 } 857 const actual = emit.outputFiles[0].text; 858 if (actual !== expected) { 859 this.raiseError(`Expected emit output to be "${expected}", but got "${actual}"`); 860 } 861 } 862 863 public verifyGetEmitOutputContentsForCurrentFile(expected: ts.OutputFile[]): void { 864 const emit = this.languageService.getEmitOutput(this.activeFile.fileName); 865 assert.equal(emit.outputFiles.length, expected.length, "Number of emit output files"); 866 ts.zipWith(emit.outputFiles, expected, (outputFile, expected) => { 867 assert.equal(outputFile.name, expected.name, "FileName"); 868 assert.equal(outputFile.text, expected.text, "Content"); 869 }); 870 } 871 872 public verifyInlayHints(expected: readonly FourSlashInterface.VerifyInlayHintsOptions[], span: ts.TextSpan = { start: 0, length: this.activeFile.content.length }, preference?: ts.UserPreferences) { 873 const hints = this.languageService.provideInlayHints(this.activeFile.fileName, span, preference); 874 assert.equal(hints.length, expected.length, "Number of hints"); 875 876 const sortHints = (a: ts.InlayHint, b: ts.InlayHint) => { 877 return a.position - b.position; 878 }; 879 ts.zipWith(hints.sort(sortHints), [...expected].sort(sortHints), (actual, expected) => { 880 assert.equal(actual.text, expected.text, "Text"); 881 assert.equal(actual.position, expected.position, "Position"); 882 assert.equal(actual.kind, expected.kind, "Kind"); 883 assert.equal(actual.whitespaceBefore, expected.whitespaceBefore, "whitespaceBefore"); 884 assert.equal(actual.whitespaceAfter, expected.whitespaceAfter, "whitespaceAfter"); 885 }); 886 } 887 888 public verifyCompletions(options: FourSlashInterface.VerifyCompletionsOptions) { 889 if (options.marker === undefined) { 890 this.verifyCompletionsWorker(options); 891 } 892 else { 893 for (const marker of toArray(options.marker)) { 894 this.goToMarker(marker); 895 this.verifyCompletionsWorker({ ...options, marker }); 896 } 897 } 898 } 899 900 private verifyCompletionsWorker(options: FourSlashInterface.VerifyCompletionsOptions): void { 901 const actualCompletions = this.getCompletionListAtCaret({ ...options.preferences, triggerCharacter: options.triggerCharacter })!; 902 if (!actualCompletions) { 903 if (ts.hasProperty(options, "exact") && (options.exact === undefined || ts.isArray(options.exact) && !options.exact.length)) { 904 return; 905 } 906 this.raiseError(`No completions at position '${this.currentCaretPosition}'.`); 907 } 908 909 if (actualCompletions.isNewIdentifierLocation !== (options.isNewIdentifierLocation || false)) { 910 this.raiseError(`Expected 'isNewIdentifierLocation' to be ${options.isNewIdentifierLocation || false}, got ${actualCompletions.isNewIdentifierLocation}`); 911 } 912 913 if (ts.hasProperty(options, "isGlobalCompletion") && actualCompletions.isGlobalCompletion !== options.isGlobalCompletion) { 914 this.raiseError(`Expected 'isGlobalCompletion to be ${options.isGlobalCompletion}, got ${actualCompletions.isGlobalCompletion}`); 915 } 916 917 if (ts.hasProperty(options, "optionalReplacementSpan")) { 918 assert.deepEqual( 919 actualCompletions.optionalReplacementSpan && actualCompletions.optionalReplacementSpan, 920 options.optionalReplacementSpan && ts.createTextSpanFromRange(options.optionalReplacementSpan), 921 "Expected 'optionalReplacementSpan' properties to match"); 922 } 923 924 const nameToEntries = new ts.Map<string, ts.CompletionEntry[]>(); 925 for (const entry of actualCompletions.entries) { 926 const entries = nameToEntries.get(entry.name); 927 if (!entries) { 928 nameToEntries.set(entry.name, [entry]); 929 } 930 else { 931 if (entries.some(e => 932 e.source === entry.source && 933 e.data?.exportName === entry.data?.exportName && 934 e.data?.fileName === entry.data?.fileName && 935 e.data?.moduleSpecifier === entry.data?.moduleSpecifier && 936 e.data?.ambientModuleName === entry.data?.ambientModuleName 937 )) { 938 this.raiseError(`Duplicate completions for ${entry.name}`); 939 } 940 entries.push(entry); 941 } 942 } 943 944 if (ts.hasProperty(options, "exact")) { 945 ts.Debug.assert(!ts.hasProperty(options, "includes") && !ts.hasProperty(options, "excludes") && !ts.hasProperty(options, "unsorted")); 946 if (options.exact === undefined) throw this.raiseError("Expected no completions"); 947 this.verifyCompletionsAreExactly(actualCompletions.entries, options.exact, options.marker); 948 } 949 else if (options.unsorted) { 950 ts.Debug.assert(!ts.hasProperty(options, "includes") && !ts.hasProperty(options, "excludes")); 951 for (const expectedEntry of options.unsorted) { 952 const name = typeof expectedEntry === "string" ? expectedEntry : expectedEntry.name; 953 const found = nameToEntries.get(name); 954 if (!found) throw this.raiseError(`Unsorted: completion '${name}' not found.`); 955 if (!found.length) throw this.raiseError(`Unsorted: no completions with name '${name}' remain unmatched.`); 956 this.verifyCompletionEntry(found.shift()!, expectedEntry); 957 } 958 if (actualCompletions.entries.length !== options.unsorted.length) { 959 const unmatched: string[] = []; 960 nameToEntries.forEach(entries => { 961 unmatched.push(...entries.map(e => e.name)); 962 }); 963 this.raiseError(`Additional completions found not included in 'unsorted': ${unmatched.join("\n")}`); 964 } 965 } 966 else { 967 if (options.includes) { 968 for (const include of toArray(options.includes)) { 969 const name = typeof include === "string" ? include : include.name; 970 const found = nameToEntries.get(name); 971 if (!found) throw this.raiseError(`Includes: completion '${name}' not found.`); 972 if (!found.length) throw this.raiseError(`Includes: no completions with name '${name}' remain unmatched.`); 973 this.verifyCompletionEntry(found.shift()!, include); 974 } 975 } 976 if (options.excludes) { 977 for (const exclude of toArray(options.excludes)) { 978 assert(typeof exclude === "string"); 979 if (nameToEntries.has(exclude)) { 980 this.raiseError(`Excludes: unexpected completion '${exclude}' found.`); 981 } 982 } 983 } 984 } 985 } 986 987 private verifyCompletionEntry(actual: ts.CompletionEntry, expected: FourSlashInterface.ExpectedCompletionEntry) { 988 expected = typeof expected === "string" ? { name: expected } : expected; 989 990 if (actual.insertText !== expected.insertText) { 991 this.raiseError(`At entry ${actual.name}: Completion insert text did not match: ${showTextDiff(expected.insertText || "", actual.insertText || "")}`); 992 } 993 const convertedReplacementSpan = expected.replacementSpan && ts.createTextSpanFromRange(expected.replacementSpan); 994 if (convertedReplacementSpan?.length) { 995 try { 996 assert.deepEqual(actual.replacementSpan, convertedReplacementSpan); 997 } 998 catch { 999 this.raiseError(`At entry ${actual.name}: Expected completion replacementSpan to be ${stringify(convertedReplacementSpan)}, got ${stringify(actual.replacementSpan)}`); 1000 } 1001 } 1002 1003 if (expected.kind !== undefined || expected.kindModifiers !== undefined) { 1004 assert.equal(actual.kind, expected.kind, `At entry ${actual.name}: Expected 'kind' for ${actual.name} to match`); 1005 assert.equal(actual.kindModifiers, expected.kindModifiers || "", `At entry ${actual.name}: Expected 'kindModifiers' for ${actual.name} to match`); 1006 } 1007 if (expected.isFromUncheckedFile !== undefined) { 1008 assert.equal<boolean | undefined>(actual.isFromUncheckedFile, expected.isFromUncheckedFile, `At entry ${actual.name}: Expected 'isFromUncheckedFile' properties to match`); 1009 } 1010 if (expected.isPackageJsonImport !== undefined) { 1011 assert.equal<boolean | undefined>(actual.isPackageJsonImport, expected.isPackageJsonImport, `At entry ${actual.name}: Expected 'isPackageJsonImport' properties to match`); 1012 } 1013 1014 assert.equal(actual.labelDetails?.description, expected.labelDetails?.description, `At entry ${actual.name}: Expected 'labelDetails.description' properties to match`); 1015 assert.equal(actual.labelDetails?.detail, expected.labelDetails?.detail, `At entry ${actual.name}: Expected 'labelDetails.detail' properties to match`); 1016 assert.equal(actual.hasAction, expected.hasAction, `At entry ${actual.name}: Expected 'hasAction' properties to match`); 1017 assert.equal(actual.isRecommended, expected.isRecommended, `At entry ${actual.name}: Expected 'isRecommended' properties to match'`); 1018 assert.equal(actual.isSnippet, expected.isSnippet, `At entry ${actual.name}: Expected 'isSnippet' properties to match`); 1019 assert.equal(actual.source, expected.source, `At entry ${actual.name}: Expected 'source' values to match`); 1020 assert.equal(actual.sortText, expected.sortText || ts.Completions.SortText.LocationPriority, `At entry ${actual.name}: Expected 'sortText' properties to match`); 1021 if (expected.sourceDisplay && actual.sourceDisplay) { 1022 assert.equal(ts.displayPartsToString(actual.sourceDisplay), expected.sourceDisplay, `At entry ${actual.name}: Expected 'sourceDisplay' properties to match`); 1023 } 1024 1025 if (expected.text !== undefined) { 1026 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}'`); 1027 assert.equal(ts.displayPartsToString(actualDetails.displayParts), expected.text, "Expected 'text' property to match 'displayParts' string"); 1028 assert.equal(ts.displayPartsToString(actualDetails.documentation), expected.documentation || "", "Expected 'documentation' property to match 'documentation' display parts string"); 1029 // TODO: GH#23587 1030 // assert.equal(actualDetails.kind, actual.kind); 1031 assert.equal(actualDetails.kindModifiers, actual.kindModifiers, "Expected 'kindModifiers' properties to match"); 1032 assert.equal(actualDetails.source && ts.displayPartsToString(actualDetails.source), expected.sourceDisplay, "Expected 'sourceDisplay' property to match 'source' display parts string"); 1033 if (!actual.sourceDisplay) { 1034 assert.equal(actualDetails.sourceDisplay && ts.displayPartsToString(actualDetails.sourceDisplay), expected.sourceDisplay, "Expected 'sourceDisplay' property to match 'sourceDisplay' display parts string"); 1035 } 1036 assert.deepEqual(actualDetails.tags, expected.tags); 1037 } 1038 else { 1039 assert(expected.documentation === undefined && expected.tags === undefined, "If specifying completion details, should specify 'text'"); 1040 } 1041 } 1042 1043 private verifyCompletionsAreExactly(actual: readonly ts.CompletionEntry[], expected: ArrayOrSingle<FourSlashInterface.ExpectedCompletionEntry> | FourSlashInterface.ExpectedExactCompletionsPlus, marker?: ArrayOrSingle<string | Marker>) { 1044 if (!ts.isArray(expected)) { 1045 expected = [expected]; 1046 } 1047 1048 // First pass: test that names are right. Then we'll test details. 1049 assert.deepEqual(actual.map(a => a.name), expected.map(e => typeof e === "string" ? e : e.name), marker ? "At marker " + JSON.stringify(marker) : undefined); 1050 1051 ts.zipWith(actual, expected, (completion, expectedCompletion, index) => { 1052 const name = typeof expectedCompletion === "string" ? expectedCompletion : expectedCompletion.name; 1053 if (completion.name !== name) { 1054 this.raiseError(`${marker ? JSON.stringify(marker) : ""} Expected completion at index ${index} to be ${name}, got ${completion.name}`); 1055 } 1056 this.verifyCompletionEntry(completion, expectedCompletion); 1057 }); 1058 1059 // All completions were correct in the sort order given. If that order was produced by a function 1060 // like `completion.globalsPlus`, ensure the "plus" array was sorted in the same way. 1061 const { plusArgument, plusFunctionName } = expected as FourSlashInterface.ExpectedExactCompletionsPlus; 1062 if (plusArgument) { 1063 assert.deepEqual( 1064 plusArgument, 1065 expected.filter(entry => plusArgument.includes(entry)), 1066 `At marker ${JSON.stringify(marker)}: Argument to '${plusFunctionName}' was incorrectly sorted.`); 1067 } 1068 } 1069 1070 /** Use `getProgram` instead of accessing this directly. */ 1071 private _program: ts.Program | undefined | "missing"; 1072 /** Use `getChecker` instead of accessing this directly. */ 1073 private _checker: ts.TypeChecker | undefined; 1074 1075 private getProgram(): ts.Program { 1076 if (!this._program) this._program = this.languageService.getProgram() || "missing"; 1077 if (this._program === "missing") ts.Debug.fail("Could not retrieve program from language service"); 1078 return this._program; 1079 } 1080 1081 private getChecker() { 1082 return this._checker || (this._checker = this.getProgram().getTypeChecker()); 1083 } 1084 1085 private getSourceFile(): ts.SourceFile { 1086 const { fileName } = this.activeFile; 1087 const result = this.getProgram().getSourceFile(fileName); 1088 if (!result) { 1089 throw new Error(`Could not get source file ${fileName}`); 1090 } 1091 return result; 1092 } 1093 1094 private getNode(): ts.Node { 1095 return ts.getTouchingPropertyName(this.getSourceFile(), this.currentCaretPosition); 1096 } 1097 1098 private goToAndGetNode(range: Range): ts.Node { 1099 this.goToRangeStart(range); 1100 const node = this.getNode(); 1101 this.verifyRange("touching property name", range, node); 1102 return node; 1103 } 1104 1105 private verifyRange(desc: string, expected: ts.TextRange, actual: ts.Node) { 1106 const actualStart = actual.getStart(); 1107 const actualEnd = actual.getEnd(); 1108 if (actualStart !== expected.pos || actualEnd !== expected.end) { 1109 this.raiseError(`${desc} should be ${expected.pos}-${expected.end}, got ${actualStart}-${actualEnd}`); 1110 } 1111 } 1112 1113 private verifySymbol(symbol: ts.Symbol, declarationRanges: Range[]) { 1114 const { declarations } = symbol; 1115 if (declarations?.length !== declarationRanges.length) { 1116 this.raiseError(`Expected to get ${declarationRanges.length} declarations, got ${declarations?.length}`); 1117 } 1118 1119 ts.zipWith(declarations, declarationRanges, (decl, range) => { 1120 this.verifyRange("symbol declaration", range, decl); 1121 }); 1122 } 1123 1124 public verifySymbolAtLocation(startRange: Range, declarationRanges: Range[]): void { 1125 const node = this.goToAndGetNode(startRange); 1126 const symbol = this.getChecker().getSymbolAtLocation(node)!; 1127 if (!symbol) { 1128 this.raiseError("Could not get symbol at location"); 1129 } 1130 this.verifySymbol(symbol, declarationRanges); 1131 } 1132 1133 public symbolsInScope(range: Range): ts.Symbol[] { 1134 const node = this.goToAndGetNode(range); 1135 return this.getChecker().getSymbolsInScope(node, ts.SymbolFlags.Value | ts.SymbolFlags.Type | ts.SymbolFlags.Namespace); 1136 } 1137 1138 public setTypesRegistry(map: ts.MapLike<void>): void { 1139 this.languageServiceAdapterHost.typesRegistry = new ts.Map(ts.getEntries(map)); 1140 } 1141 1142 public verifyTypeOfSymbolAtLocation(range: Range, symbol: ts.Symbol, expected: string): void { 1143 const node = this.goToAndGetNode(range); 1144 const checker = this.getChecker(); 1145 const type = checker.getTypeOfSymbolAtLocation(symbol, node); 1146 1147 const actual = checker.typeToString(type); 1148 if (actual !== expected) { 1149 this.raiseError(displayExpectedAndActualString(expected, actual)); 1150 } 1151 } 1152 1153 public verifyBaselineFindAllReferences(...markerNames: string[]) { 1154 ts.Debug.assert(markerNames.length > 0, "Must pass at least one marker name to `verifyBaselineFindAllReferences()`"); 1155 this.verifyBaselineFindAllReferencesWorker("", markerNames); 1156 } 1157 1158 // Used when a single test needs to produce multiple baselines 1159 public verifyBaselineFindAllReferencesMulti(seq: number, ...markerNames: string[]) { 1160 ts.Debug.assert(markerNames.length > 0, "Must pass at least one marker name to `baselineFindAllReferences()`"); 1161 this.verifyBaselineFindAllReferencesWorker(`.${seq}`, markerNames); 1162 } 1163 1164 private verifyBaselineFindAllReferencesWorker(suffix: string, markerNames: string[]) { 1165 const baseline = markerNames.map(markerName => { 1166 this.goToMarker(markerName); 1167 const marker = this.getMarkerByName(markerName); 1168 const references = this.languageService.findReferences(marker.fileName, marker.position); 1169 const refsByFile = references 1170 ? ts.group(ts.sort(ts.flatMap(references, r => r.references), (a, b) => a.textSpan.start - b.textSpan.start), ref => ref.fileName) 1171 : ts.emptyArray; 1172 1173 // Write input files 1174 const baselineContent = this.getBaselineContentForGroupedReferences(refsByFile, markerName); 1175 1176 // Write response JSON 1177 return baselineContent + JSON.stringify(references, undefined, 2); 1178 }).join("\n\n"); 1179 Harness.Baseline.runBaseline(this.getBaselineFileNameForContainingTestFile(`${suffix}.baseline.jsonc`), baseline); 1180 } 1181 1182 public verifyBaselineGetFileReferences(fileName: string) { 1183 const references = this.languageService.getFileReferences(fileName); 1184 const refsByFile = references 1185 ? ts.group(ts.sort(references, (a, b) => a.textSpan.start - b.textSpan.start), ref => ref.fileName) 1186 : ts.emptyArray; 1187 1188 // Write input files 1189 let baselineContent = this.getBaselineContentForGroupedReferences(refsByFile); 1190 1191 // Write response JSON 1192 baselineContent += JSON.stringify(references, undefined, 2); 1193 Harness.Baseline.runBaseline(this.getBaselineFileNameForContainingTestFile(".baseline.jsonc"), baselineContent); 1194 } 1195 1196 private getBaselineContentForGroupedReferences(refsByFile: readonly (readonly ts.ReferenceEntry[])[], markerName?: string) { 1197 const marker = markerName !== undefined ? this.getMarkerByName(markerName) : undefined; 1198 let baselineContent = ""; 1199 for (const group of refsByFile) { 1200 baselineContent += getBaselineContentForFile(group[0].fileName, this.getFileContent(group[0].fileName)); 1201 baselineContent += "\n\n"; 1202 } 1203 return baselineContent; 1204 1205 function getBaselineContentForFile(fileName: string, content: string) { 1206 let newContent = `=== ${fileName} ===\n`; 1207 let pos = 0; 1208 for (const { textSpan } of refsByFile.find(refs => refs[0].fileName === fileName) ?? ts.emptyArray) { 1209 const end = textSpan.start + textSpan.length; 1210 if (fileName === marker?.fileName && pos <= marker.position && marker.position < textSpan.start) { 1211 newContent += content.slice(pos, marker.position); 1212 newContent += "/*FIND ALL REFS*/"; 1213 pos = marker.position; 1214 } 1215 newContent += content.slice(pos, textSpan.start); 1216 pos = textSpan.start; 1217 // It's easier to read if the /*FIND ALL REFS*/ comment is outside the range markers, which makes 1218 // this code a bit more verbose than it would be if I were less picky about the baseline format. 1219 if (fileName === marker?.fileName && marker.position === textSpan.start) { 1220 newContent += "/*FIND ALL REFS*/"; 1221 newContent += "[|"; 1222 } 1223 else if (fileName === marker?.fileName && ts.textSpanContainsPosition(textSpan, marker.position)) { 1224 newContent += "[|"; 1225 newContent += content.slice(pos, marker.position); 1226 newContent += "/*FIND ALL REFS*/"; 1227 pos = marker.position; 1228 } 1229 else { 1230 newContent += "[|"; 1231 } 1232 newContent += content.slice(pos, end); 1233 newContent += "|]"; 1234 pos = end; 1235 } 1236 if (marker?.fileName === fileName && marker.position >= pos) { 1237 newContent += content.slice(pos, marker.position); 1238 newContent += "/*FIND ALL REFS*/"; 1239 pos = marker.position; 1240 } 1241 newContent += content.slice(pos); 1242 return newContent.split(/\r?\n/).map(l => "// " + l).join("\n"); 1243 } 1244 } 1245 1246 private assertObjectsEqual<T>(fullActual: T, fullExpected: T, msgPrefix = ""): void { 1247 const recur = <U>(actual: U, expected: U, path: string) => { 1248 const fail = (msg: string) => { 1249 this.raiseError(`${msgPrefix} At ${path}: ${msg} ${displayExpectedAndActualString(stringify(fullExpected), stringify(fullActual))}`); 1250 }; 1251 1252 if ((actual === undefined) !== (expected === undefined)) { 1253 fail(`Expected ${stringify(expected)}, got ${stringify(actual)}`); 1254 } 1255 1256 for (const key in actual) { 1257 if (ts.hasProperty(actual as any, key)) { 1258 const ak = actual[key], ek = expected[key]; 1259 if (typeof ak === "object" && typeof ek === "object") { 1260 recur(ak, ek, path ? path + "." + key : key); 1261 } 1262 else if (ak !== ek) { 1263 fail(`Expected '${key}' to be '${stringify(ek)}', got '${stringify(ak)}'`); 1264 } 1265 } 1266 } 1267 1268 for (const key in expected) { 1269 if (ts.hasProperty(expected as any, key)) { 1270 if (!ts.hasProperty(actual as any, key)) { 1271 fail(`${msgPrefix}Missing property '${key}'`); 1272 } 1273 } 1274 } 1275 }; 1276 1277 if (fullActual === undefined || fullExpected === undefined) { 1278 if (fullActual === fullExpected) { 1279 return; 1280 } 1281 this.raiseError(`${msgPrefix} ${displayExpectedAndActualString(stringify(fullExpected), stringify(fullActual))}`); 1282 } 1283 recur(fullActual, fullExpected, ""); 1284 1285 } 1286 1287 public verifyDisplayPartsOfReferencedSymbol(expected: ts.SymbolDisplayPart[]) { 1288 const referencedSymbols = this.findReferencesAtCaret()!; 1289 1290 if (referencedSymbols.length === 0) { 1291 this.raiseError("No referenced symbols found at current caret position"); 1292 } 1293 else if (referencedSymbols.length > 1) { 1294 this.raiseError("More than one referenced symbol found"); 1295 } 1296 1297 assert.equal(TestState.getDisplayPartsJson(referencedSymbols[0].definition.displayParts), 1298 TestState.getDisplayPartsJson(expected), this.messageAtLastKnownMarker("referenced symbol definition display parts")); 1299 } 1300 1301 private configure(preferences: ts.UserPreferences) { 1302 if (this.testType === FourSlashTestType.Server) { 1303 (this.languageService as ts.server.SessionClient).configure(preferences); 1304 } 1305 } 1306 1307 private getCompletionListAtCaret(options?: ts.GetCompletionsAtPositionOptions): ts.CompletionInfo | undefined { 1308 if (options) { 1309 this.configure(options); 1310 } 1311 return this.languageService.getCompletionsAtPosition( 1312 this.activeFile.fileName, 1313 this.currentCaretPosition, 1314 options, 1315 this.formatCodeSettings); 1316 } 1317 1318 private getCompletionEntryDetails(entryName: string, source: string | undefined, data: ts.CompletionEntryData | undefined, preferences?: ts.UserPreferences): ts.CompletionEntryDetails | undefined { 1319 if (preferences) { 1320 this.configure(preferences); 1321 } 1322 return this.languageService.getCompletionEntryDetails(this.activeFile.fileName, this.currentCaretPosition, entryName, this.formatCodeSettings, source, preferences, data); 1323 } 1324 1325 private findReferencesAtCaret() { 1326 return this.languageService.findReferences(this.activeFile.fileName, this.currentCaretPosition); 1327 } 1328 1329 public getSyntacticDiagnostics(expected: readonly FourSlashInterface.Diagnostic[]) { 1330 const diagnostics = this.languageService.getSyntacticDiagnostics(this.activeFile.fileName); 1331 this.testDiagnostics(expected, diagnostics, "error"); 1332 } 1333 1334 public getSemanticDiagnostics(expected: readonly FourSlashInterface.Diagnostic[]) { 1335 const diagnostics = this.languageService.getSemanticDiagnostics(this.activeFile.fileName); 1336 this.testDiagnostics(expected, diagnostics, "error"); 1337 } 1338 1339 public getSuggestionDiagnostics(expected: readonly FourSlashInterface.Diagnostic[]): void { 1340 this.testDiagnostics(expected, this.languageService.getSuggestionDiagnostics(this.activeFile.fileName), "suggestion"); 1341 } 1342 1343 private testDiagnostics(expected: readonly FourSlashInterface.Diagnostic[], diagnostics: readonly ts.Diagnostic[], category: string) { 1344 assert.deepEqual(ts.realizeDiagnostics(diagnostics, "\n"), expected.map((e): ts.RealizedDiagnostic => { 1345 const range = e.range || this.getRangesInFile()[0]; 1346 if (!range) { 1347 this.raiseError("Must provide a range for each expected diagnostic, or have one range in the fourslash source."); 1348 } 1349 return { 1350 message: e.message, 1351 category, 1352 code: e.code, 1353 ...ts.createTextSpanFromRange(range), 1354 reportsUnnecessary: e.reportsUnnecessary, 1355 reportsDeprecated: e.reportsDeprecated 1356 }; 1357 })); 1358 } 1359 1360 public verifyQuickInfoAt(markerName: string | Range, expectedText: string, expectedDocumentation?: string, expectedTags?: {name: string; text: string;}[]) { 1361 if (typeof markerName === "string") this.goToMarker(markerName); 1362 else this.goToRangeStart(markerName); 1363 1364 this.verifyQuickInfoString(expectedText, expectedDocumentation, expectedTags); 1365 } 1366 1367 public verifyQuickInfos(namesAndTexts: { [name: string]: string | [string, string] }) { 1368 for (const name in namesAndTexts) { 1369 if (ts.hasProperty(namesAndTexts, name)) { 1370 const text = namesAndTexts[name]; 1371 if (ts.isArray(text)) { 1372 assert(text.length === 2); 1373 const [expectedText, expectedDocumentation] = text; 1374 this.verifyQuickInfoAt(name, expectedText, expectedDocumentation); 1375 } 1376 else { 1377 this.verifyQuickInfoAt(name, text); 1378 } 1379 } 1380 } 1381 } 1382 1383 public verifyQuickInfoString(expectedText: string, expectedDocumentation?: string, expectedTags?: { name: string; text: string; }[]) { 1384 if (expectedDocumentation === "") { 1385 throw new Error("Use 'undefined' instead of empty string for `expectedDocumentation`"); 1386 } 1387 const actualQuickInfo = this.languageService.getQuickInfoAtPosition(this.activeFile.fileName, this.currentCaretPosition); 1388 const actualQuickInfoText = ts.displayPartsToString(actualQuickInfo?.displayParts); 1389 const actualQuickInfoDocumentation = ts.displayPartsToString(actualQuickInfo?.documentation); 1390 const actualQuickInfoTags = actualQuickInfo?.tags?.map(tag => ({ name: tag.name, text: ts.getTextOfJSDocTagInfo(tag) })); 1391 1392 assert.equal(actualQuickInfoText, expectedText, this.messageAtLastKnownMarker("quick info text")); 1393 assert.equal(actualQuickInfoDocumentation, expectedDocumentation || "", this.assertionMessageAtLastKnownMarker("quick info doc")); 1394 if (!expectedTags) { 1395 // Skip if `expectedTags` is not given 1396 } 1397 else if (!actualQuickInfoTags) { 1398 assert.equal(actualQuickInfoTags, expectedTags, this.messageAtLastKnownMarker("QuickInfo tags")); 1399 } 1400 else { 1401 ts.zipWith(expectedTags, actualQuickInfoTags, (expectedTag, actualTag) => { 1402 assert.equal(expectedTag.name, actualTag.name); 1403 assert.equal(expectedTag.text, actualTag.text, this.messageAtLastKnownMarker("QuickInfo tag " + actualTag.name)); 1404 }); 1405 } 1406 } 1407 1408 public verifyQuickInfoDisplayParts(kind: string, kindModifiers: string, textSpan: TextSpan, 1409 displayParts: ts.SymbolDisplayPart[], 1410 documentation: ts.SymbolDisplayPart[], 1411 tags: ts.JSDocTagInfo[] | undefined 1412 ) { 1413 1414 const actualQuickInfo = this.languageService.getQuickInfoAtPosition(this.activeFile.fileName, this.currentCaretPosition)!; 1415 assert.equal(actualQuickInfo.kind, kind, this.messageAtLastKnownMarker("QuickInfo kind")); 1416 assert.equal(actualQuickInfo.kindModifiers, kindModifiers, this.messageAtLastKnownMarker("QuickInfo kindModifiers")); 1417 assert.equal(JSON.stringify(actualQuickInfo.textSpan), JSON.stringify(textSpan), this.messageAtLastKnownMarker("QuickInfo textSpan")); 1418 assert.equal(TestState.getDisplayPartsJson(actualQuickInfo.displayParts), TestState.getDisplayPartsJson(displayParts), this.messageAtLastKnownMarker("QuickInfo displayParts")); 1419 assert.equal(TestState.getDisplayPartsJson(actualQuickInfo.documentation), TestState.getDisplayPartsJson(documentation), this.messageAtLastKnownMarker("QuickInfo documentation")); 1420 if (!actualQuickInfo.tags || !tags) { 1421 assert.equal(actualQuickInfo.tags, tags, this.messageAtLastKnownMarker("QuickInfo tags")); 1422 } 1423 else { 1424 assert.equal(actualQuickInfo.tags.length, tags.length, this.messageAtLastKnownMarker("QuickInfo tags")); 1425 ts.zipWith(tags, actualQuickInfo.tags, (expectedTag, actualTag) => { 1426 assert.equal(expectedTag.name, actualTag.name); 1427 assert.equal(expectedTag.text, actualTag.text, this.messageAtLastKnownMarker("QuickInfo tag " + actualTag.name)); 1428 }); 1429 } 1430 } 1431 1432 public verifyRangesAreRenameLocations(options?: Range[] | { findInStrings?: boolean, findInComments?: boolean, ranges?: Range[] }) { 1433 if (ts.isArray(options)) { 1434 this.verifyRenameLocations(options, options); 1435 } 1436 else { 1437 const ranges = options && options.ranges || this.getRanges(); 1438 this.verifyRenameLocations(ranges, { ranges, ...options }); 1439 } 1440 } 1441 1442 public verifyRenameLocations(startRanges: ArrayOrSingle<Range>, options: FourSlashInterface.RenameLocationsOptions) { 1443 interface RangeMarkerData { 1444 id?: string; 1445 contextRangeIndex?: number, 1446 contextRangeDelta?: number 1447 contextRangeId?: string; 1448 } 1449 const { findInStrings = false, findInComments = false, ranges = this.getRanges(), providePrefixAndSuffixTextForRename = true } = 1450 ts.isArray(options) 1451 ? { findInStrings: false, findInComments: false, ranges: options, providePrefixAndSuffixTextForRename: true } 1452 : options; 1453 1454 const _startRanges = toArray(startRanges); 1455 assert(_startRanges.length); 1456 for (const startRange of _startRanges) { 1457 this.goToRangeStart(startRange); 1458 1459 const renameInfo = this.languageService.getRenameInfo(this.activeFile.fileName, this.currentCaretPosition, { providePrefixAndSuffixTextForRename }); 1460 if (!renameInfo.canRename) { 1461 this.raiseError("Expected rename to succeed, but it actually failed."); 1462 } 1463 1464 const references = this.languageService.findRenameLocations( 1465 this.activeFile.fileName, this.currentCaretPosition, findInStrings, findInComments, providePrefixAndSuffixTextForRename); 1466 1467 const sort = (locations: readonly ts.RenameLocation[] | undefined) => 1468 locations && ts.sort(locations, (r1, r2) => ts.compareStringsCaseSensitive(r1.fileName, r2.fileName) || r1.textSpan.start - r2.textSpan.start); 1469 assert.deepEqual(sort(references), sort(ranges.map((rangeOrOptions): ts.RenameLocation => { 1470 const { range, ...prefixSuffixText } = "range" in rangeOrOptions ? rangeOrOptions : { range: rangeOrOptions }; // eslint-disable-line local/no-in-operator 1471 const { contextRangeIndex, contextRangeDelta, contextRangeId } = (range.marker && range.marker.data || {}) as RangeMarkerData; 1472 let contextSpan: ts.TextSpan | undefined; 1473 if (contextRangeDelta !== undefined) { 1474 const allRanges = this.getRanges(); 1475 const index = allRanges.indexOf(range); 1476 if (index !== -1) { 1477 contextSpan = ts.createTextSpanFromRange(allRanges[index + contextRangeDelta]); 1478 } 1479 } 1480 else if (contextRangeId !== undefined) { 1481 const allRanges = this.getRanges(); 1482 const contextRange = ts.find(allRanges, range => (range.marker?.data as RangeMarkerData)?.id === contextRangeId); 1483 if (contextRange) { 1484 contextSpan = ts.createTextSpanFromRange(contextRange); 1485 } 1486 } 1487 else if (contextRangeIndex !== undefined) { 1488 contextSpan = ts.createTextSpanFromRange(this.getRanges()[contextRangeIndex]); 1489 } 1490 return { 1491 fileName: range.fileName, 1492 textSpan: ts.createTextSpanFromRange(range), 1493 ...(contextSpan ? { contextSpan } : undefined), 1494 ...prefixSuffixText 1495 }; 1496 }))); 1497 } 1498 } 1499 1500 public baselineRename(marker: string, options: FourSlashInterface.RenameOptions) { 1501 const { fileName, position } = this.getMarkerByName(marker); 1502 const locations = this.languageService.findRenameLocations( 1503 fileName, 1504 position, 1505 options.findInStrings ?? false, 1506 options.findInComments ?? false, 1507 options.providePrefixAndSuffixTextForRename); 1508 1509 if (!locations) { 1510 this.raiseError(`baselineRename failed. Could not rename at the provided position.`); 1511 } 1512 1513 const renamesByFile = ts.group(locations, l => l.fileName); 1514 const baselineContent = renamesByFile.map(renames => { 1515 const { fileName } = renames[0]; 1516 const sortedRenames = ts.sort(renames, (a, b) => b.textSpan.start - a.textSpan.start); 1517 let baselineFileContent = this.getFileContent(fileName); 1518 for (const { textSpan } of sortedRenames) { 1519 const isOriginalSpan = fileName === this.activeFile.fileName && ts.textSpanIntersectsWithPosition(textSpan, position); 1520 baselineFileContent = 1521 baselineFileContent.slice(0, textSpan.start) + 1522 (isOriginalSpan ? "[|RENAME|]" : "RENAME") + 1523 baselineFileContent.slice(textSpan.start + textSpan.length); 1524 } 1525 return `/*====== ${fileName} ======*/\n\n${baselineFileContent}`; 1526 }).join("\n\n") + "\n"; 1527 1528 Harness.Baseline.runBaseline(this.getBaselineFileNameForContainingTestFile(), baselineContent); 1529 } 1530 1531 public verifyQuickInfoExists(negative: boolean) { 1532 const actualQuickInfo = this.languageService.getQuickInfoAtPosition(this.activeFile.fileName, this.currentCaretPosition); 1533 if (negative) { 1534 if (actualQuickInfo) { 1535 this.raiseError("verifyQuickInfoExists failed. Expected quick info NOT to exist"); 1536 } 1537 } 1538 else { 1539 if (!actualQuickInfo) { 1540 this.raiseError("verifyQuickInfoExists failed. Expected quick info to exist"); 1541 } 1542 } 1543 } 1544 1545 public verifySignatureHelpPresence(expectPresent: boolean, triggerReason: ts.SignatureHelpTriggerReason | undefined, markers: readonly (string | Marker)[]) { 1546 if (markers.length) { 1547 for (const marker of markers) { 1548 this.goToMarker(marker); 1549 this.verifySignatureHelpPresence(expectPresent, triggerReason, ts.emptyArray); 1550 } 1551 return; 1552 } 1553 const actual = this.getSignatureHelp({ triggerReason }); 1554 if (expectPresent !== !!actual) { 1555 if (actual) { 1556 this.raiseError(`Expected no signature help, but got "${stringify(actual)}"`); 1557 } 1558 else { 1559 this.raiseError("Expected signature help, but none was returned."); 1560 } 1561 } 1562 } 1563 1564 public verifySignatureHelp(optionses: readonly FourSlashInterface.VerifySignatureHelpOptions[]) { 1565 for (const options of optionses) { 1566 if (options.marker === undefined) { 1567 this.verifySignatureHelpWorker(options); 1568 } 1569 else { 1570 for (const marker of toArray(options.marker)) { 1571 this.goToMarker(marker); 1572 this.verifySignatureHelpWorker(options); 1573 } 1574 } 1575 } 1576 } 1577 1578 private verifySignatureHelpWorker(options: FourSlashInterface.VerifySignatureHelpOptions) { 1579 const help = this.getSignatureHelp({ triggerReason: options.triggerReason })!; 1580 if (!help) { 1581 this.raiseError("Could not get a help signature"); 1582 } 1583 1584 const selectedItem = help.items[options.overrideSelectedItemIndex ?? help.selectedItemIndex]; 1585 // Argument index may exceed number of parameters 1586 const currentParameter = selectedItem.parameters[help.argumentIndex] as ts.SignatureHelpParameter | undefined; 1587 1588 assert.equal(help.items.length, options.overloadsCount || 1, this.assertionMessageAtLastKnownMarker("signature help overloads count")); 1589 1590 assert.equal(ts.displayPartsToString(selectedItem.documentation), options.docComment || "", this.assertionMessageAtLastKnownMarker("current signature help doc comment")); 1591 1592 if (options.text !== undefined) { 1593 assert.equal( 1594 ts.displayPartsToString(selectedItem.prefixDisplayParts) + 1595 selectedItem.parameters.map(p => ts.displayPartsToString(p.displayParts)).join(ts.displayPartsToString(selectedItem.separatorDisplayParts)) + 1596 ts.displayPartsToString(selectedItem.suffixDisplayParts), options.text); 1597 } 1598 if (options.parameterName !== undefined) { 1599 assert.equal(currentParameter!.name, options.parameterName); 1600 } 1601 if (options.parameterSpan !== undefined) { 1602 assert.equal(ts.displayPartsToString(currentParameter!.displayParts), options.parameterSpan); 1603 } 1604 if (currentParameter) { 1605 assert.equal(ts.displayPartsToString(currentParameter.documentation), options.parameterDocComment || "", this.assertionMessageAtLastKnownMarker("current parameter Help DocComment")); 1606 } 1607 if (options.parameterCount !== undefined) { 1608 assert.equal(selectedItem.parameters.length, options.parameterCount); 1609 } 1610 if (options.argumentCount !== undefined) { 1611 assert.equal(help.argumentCount, options.argumentCount); 1612 } 1613 1614 assert.equal(selectedItem.isVariadic, !!options.isVariadic); 1615 1616 const actualTags = selectedItem.tags; 1617 assert.equal(actualTags.length, (options.tags || ts.emptyArray).length, this.assertionMessageAtLastKnownMarker("signature help tags")); 1618 ts.zipWith((options.tags || ts.emptyArray), actualTags, (expectedTag, actualTag) => { 1619 assert.equal(actualTag.name, expectedTag.name); 1620 assert.deepEqual(actualTag.text, expectedTag.text, this.assertionMessageAtLastKnownMarker("signature help tag " + actualTag.name)); 1621 }); 1622 1623 const allKeys: readonly (keyof FourSlashInterface.VerifySignatureHelpOptions)[] = [ 1624 "marker", 1625 "triggerReason", 1626 "overloadsCount", 1627 "docComment", 1628 "text", 1629 "parameterName", 1630 "parameterSpan", 1631 "parameterDocComment", 1632 "parameterCount", 1633 "isVariadic", 1634 "tags", 1635 "argumentCount", 1636 "overrideSelectedItemIndex" 1637 ]; 1638 for (const key in options) { 1639 if (!ts.contains(allKeys, key)) { 1640 ts.Debug.fail("Unexpected key " + key); 1641 } 1642 } 1643 } 1644 1645 private validate(name: string, expected: string | undefined, actual: string | undefined) { 1646 if (expected && expected !== actual) { 1647 this.raiseError("Expected " + name + " '" + expected + "'. Got '" + actual + "' instead."); 1648 } 1649 } 1650 1651 public verifyRenameInfoSucceeded( 1652 displayName: string | undefined, 1653 fullDisplayName: string | undefined, 1654 kind: string | undefined, 1655 kindModifiers: string | undefined, 1656 fileToRename: string | undefined, 1657 expectedRange: Range | undefined, 1658 preferences: ts.UserPreferences | undefined): void { 1659 const renameInfo = this.languageService.getRenameInfo(this.activeFile.fileName, this.currentCaretPosition, preferences || { allowRenameOfImportPath: true }); 1660 if (!renameInfo.canRename) { 1661 throw this.raiseError("Rename did not succeed"); 1662 } 1663 1664 this.validate("displayName", displayName, renameInfo.displayName); 1665 this.validate("fullDisplayName", fullDisplayName, renameInfo.fullDisplayName); 1666 this.validate("kind", kind, renameInfo.kind); 1667 this.validate("kindModifiers", kindModifiers, renameInfo.kindModifiers); 1668 this.validate("fileToRename", fileToRename, renameInfo.fileToRename); 1669 1670 if (!expectedRange) { 1671 if (this.getRanges().length !== 1) { 1672 this.raiseError("Expected a single range to be selected in the test file."); 1673 } 1674 expectedRange = this.getRanges()[0]; 1675 } 1676 1677 if (renameInfo.triggerSpan.start !== expectedRange.pos || 1678 ts.textSpanEnd(renameInfo.triggerSpan) !== expectedRange.end) { 1679 this.raiseError("Expected triggerSpan [" + expectedRange.pos + "," + expectedRange.end + "). Got [" + 1680 renameInfo.triggerSpan.start + "," + ts.textSpanEnd(renameInfo.triggerSpan) + ") instead."); 1681 } 1682 } 1683 1684 public verifyRenameInfoFailed(message?: string, preferences?: ts.UserPreferences) { 1685 const allowRenameOfImportPath = preferences?.allowRenameOfImportPath === undefined ? true : preferences.allowRenameOfImportPath; 1686 const renameInfo = this.languageService.getRenameInfo(this.activeFile.fileName, this.currentCaretPosition, { ...preferences, allowRenameOfImportPath }); 1687 if (renameInfo.canRename) { 1688 throw this.raiseError("Rename was expected to fail"); 1689 } 1690 this.validate("error", message, renameInfo.localizedErrorMessage); 1691 } 1692 1693 private alignmentForExtraInfo = 50; 1694 1695 private spanLines(file: FourSlashFile, spanInfo: ts.TextSpan, { selection = false, fullLines = false, lineNumbers = false } = {}) { 1696 if (selection) { 1697 fullLines = true; 1698 } 1699 1700 let contextStartPos = spanInfo.start; 1701 let contextEndPos = contextStartPos + spanInfo.length; 1702 if (fullLines) { 1703 if (contextStartPos > 0) { 1704 while (contextStartPos > 1) { 1705 const ch = file.content.charCodeAt(contextStartPos - 1); 1706 if (ch === ts.CharacterCodes.lineFeed || ch === ts.CharacterCodes.carriageReturn) { 1707 break; 1708 } 1709 contextStartPos--; 1710 } 1711 } 1712 if (contextEndPos < file.content.length) { 1713 while (contextEndPos < file.content.length - 1) { 1714 const ch = file.content.charCodeAt(contextEndPos); 1715 if (ch === ts.CharacterCodes.lineFeed || ch === ts.CharacterCodes.carriageReturn) { 1716 break; 1717 } 1718 contextEndPos++; 1719 } 1720 } 1721 } 1722 1723 let contextString: string; 1724 let contextLineMap: number[]; 1725 let contextStart: ts.LineAndCharacter; 1726 let contextEnd: ts.LineAndCharacter; 1727 let selectionStart: ts.LineAndCharacter; 1728 let selectionEnd: ts.LineAndCharacter; 1729 let lineNumberPrefixLength: number; 1730 if (lineNumbers) { 1731 contextString = file.content; 1732 contextLineMap = ts.computeLineStarts(contextString); 1733 contextStart = ts.computeLineAndCharacterOfPosition(contextLineMap, contextStartPos); 1734 contextEnd = ts.computeLineAndCharacterOfPosition(contextLineMap, contextEndPos); 1735 selectionStart = ts.computeLineAndCharacterOfPosition(contextLineMap, spanInfo.start); 1736 selectionEnd = ts.computeLineAndCharacterOfPosition(contextLineMap, ts.textSpanEnd(spanInfo)); 1737 lineNumberPrefixLength = (contextEnd.line + 1).toString().length + 2; 1738 } 1739 else { 1740 contextString = file.content.substring(contextStartPos, contextEndPos); 1741 contextLineMap = ts.computeLineStarts(contextString); 1742 contextStart = { line: 0, character: 0 }; 1743 contextEnd = { line: contextLineMap.length - 1, character: 0 }; 1744 selectionStart = selection ? ts.computeLineAndCharacterOfPosition(contextLineMap, spanInfo.start - contextStartPos) : contextStart; 1745 selectionEnd = selection ? ts.computeLineAndCharacterOfPosition(contextLineMap, ts.textSpanEnd(spanInfo) - contextStartPos) : contextEnd; 1746 lineNumberPrefixLength = 0; 1747 } 1748 1749 const output: string[] = []; 1750 for (let lineNumber = contextStart.line; lineNumber <= contextEnd.line; lineNumber++) { 1751 const spanLine = contextString.substring(contextLineMap[lineNumber], contextLineMap[lineNumber + 1]); 1752 output.push(lineNumbers ? `${ts.padLeft(`${lineNumber + 1}: `, lineNumberPrefixLength)}${spanLine}` : spanLine); 1753 if (selection) { 1754 if (lineNumber < selectionStart.line || lineNumber > selectionEnd.line) { 1755 continue; 1756 } 1757 1758 const isEmpty = selectionStart.line === selectionEnd.line && selectionStart.character === selectionEnd.character; 1759 const selectionPadLength = lineNumber === selectionStart.line ? selectionStart.character : 0; 1760 const selectionPad = " ".repeat(selectionPadLength + lineNumberPrefixLength); 1761 const selectionLength = isEmpty ? 0 : Math.max(lineNumber < selectionEnd.line ? spanLine.trimRight().length - selectionPadLength : selectionEnd.character - selectionPadLength, 1); 1762 const selectionLine = isEmpty ? "<" : "^".repeat(selectionLength); 1763 output.push(`${selectionPad}${selectionLine}`); 1764 } 1765 } 1766 return output; 1767 } 1768 1769 private spanInfoToString(spanInfo: ts.TextSpan, prefixString: string, file: FourSlashFile = this.activeFile) { 1770 let resultString = "SpanInfo: " + JSON.stringify(spanInfo); 1771 if (spanInfo) { 1772 const spanLines = this.spanLines(file, spanInfo); 1773 for (let i = 0; i < spanLines.length; i++) { 1774 if (!i) { 1775 resultString += "\n"; 1776 } 1777 resultString += prefixString + spanLines[i]; 1778 } 1779 resultString += "\n" + prefixString + ":=> (" + this.getLineColStringAtPosition(spanInfo.start, file) + ") to (" + this.getLineColStringAtPosition(ts.textSpanEnd(spanInfo), file) + ")"; 1780 } 1781 1782 return resultString; 1783 } 1784 1785 private baselineCurrentFileLocations(getSpanAtPos: (pos: number) => ts.TextSpan): string { 1786 const fileLineMap = ts.computeLineStarts(this.activeFile.content); 1787 let nextLine = 0; 1788 let resultString = ""; 1789 let currentLine: string; 1790 let previousSpanInfo: string | undefined; 1791 let startColumn: number | undefined; 1792 let length: number | undefined; 1793 const prefixString = " >"; 1794 1795 let pos = 0; 1796 const addSpanInfoString = () => { 1797 if (previousSpanInfo) { 1798 resultString += currentLine; 1799 let thisLineMarker = ts.repeatString(" ", startColumn!) + ts.repeatString("~", length!); 1800 thisLineMarker += ts.repeatString(" ", this.alignmentForExtraInfo - thisLineMarker.length - prefixString.length + 1); 1801 resultString += thisLineMarker; 1802 resultString += "=> Pos: (" + (pos - length!) + " to " + (pos - 1) + ") "; 1803 resultString += " " + previousSpanInfo; 1804 previousSpanInfo = undefined; 1805 } 1806 }; 1807 1808 for (; pos < this.activeFile.content.length; pos++) { 1809 if (pos === 0 || pos === fileLineMap[nextLine]) { 1810 nextLine++; 1811 addSpanInfoString(); 1812 if (resultString.length) { 1813 resultString += "\n--------------------------------"; 1814 } 1815 currentLine = "\n" + nextLine.toString() + ts.repeatString(" ", 3 - nextLine.toString().length) + ">" + this.activeFile.content.substring(pos, fileLineMap[nextLine]) + "\n "; 1816 startColumn = 0; 1817 length = 0; 1818 } 1819 const spanInfo = this.spanInfoToString(getSpanAtPos(pos), prefixString); 1820 if (previousSpanInfo && previousSpanInfo !== spanInfo) { 1821 addSpanInfoString(); 1822 previousSpanInfo = spanInfo; 1823 startColumn = startColumn! + length!; 1824 length = 1; 1825 } 1826 else { 1827 previousSpanInfo = spanInfo; 1828 length!++; 1829 } 1830 } 1831 addSpanInfoString(); 1832 return resultString; 1833 } 1834 1835 public getBreakpointStatementLocation(pos: number) { 1836 return this.languageService.getBreakpointStatementAtPosition(this.activeFile.fileName, pos); 1837 } 1838 1839 public baselineCurrentFileBreakpointLocations() { 1840 const baselineFile = this.getBaselineFileNameForInternalFourslashFile().replace("breakpointValidation", "bpSpan"); 1841 Harness.Baseline.runBaseline(baselineFile, this.baselineCurrentFileLocations(pos => this.getBreakpointStatementLocation(pos)!)); 1842 } 1843 1844 private getEmitFiles(): readonly FourSlashFile[] { 1845 // Find file to be emitted 1846 const emitFiles: FourSlashFile[] = []; // List of FourSlashFile that has emitThisFile flag on 1847 1848 const allFourSlashFiles = this.testData.files; 1849 for (const file of allFourSlashFiles) { 1850 if (file.fileOptions[MetadataOptionNames.emitThisFile] === "true") { 1851 // Find a file with the flag emitThisFile turned on 1852 emitFiles.push(file); 1853 } 1854 } 1855 1856 // If there is not emiThisFile flag specified in the test file, throw an error 1857 if (emitFiles.length === 0) { 1858 this.raiseError("No emitThisFile is specified in the test file"); 1859 } 1860 1861 return emitFiles; 1862 } 1863 1864 public verifyGetEmitOutput(expectedOutputFiles: readonly string[]): void { 1865 const outputFiles = ts.flatMap(this.getEmitFiles(), e => this.languageService.getEmitOutput(e.fileName).outputFiles); 1866 1867 assert.deepEqual(outputFiles.map(f => f.name), expectedOutputFiles); 1868 1869 for (const { name, text } of outputFiles) { 1870 const fromTestFile = this.getFileContent(name); 1871 if (fromTestFile !== text) { 1872 this.raiseError(`Emit output for ${name} is not as expected: ${showTextDiff(fromTestFile, text)}`); 1873 } 1874 } 1875 } 1876 1877 public baselineGetEmitOutput(): void { 1878 let resultString = ""; 1879 // Loop through all the emittedFiles and emit them one by one 1880 for (const emitFile of this.getEmitFiles()) { 1881 const emitOutput = this.languageService.getEmitOutput(emitFile.fileName); 1882 // Print emitOutputStatus in readable format 1883 resultString += "EmitSkipped: " + emitOutput.emitSkipped + Harness.IO.newLine(); 1884 1885 if (emitOutput.emitSkipped) { 1886 resultString += "Diagnostics:" + Harness.IO.newLine(); 1887 const diagnostics = ts.getPreEmitDiagnostics(this.languageService.getProgram()!); // TODO: GH#18217 1888 for (const diagnostic of diagnostics) { 1889 if (!ts.isString(diagnostic.messageText)) { 1890 resultString += this.flattenChainedMessage(diagnostic.messageText); 1891 } 1892 else { 1893 resultString += " " + diagnostic.messageText + Harness.IO.newLine(); 1894 } 1895 } 1896 } 1897 1898 for (const outputFile of emitOutput.outputFiles) { 1899 const fileName = "FileName : " + outputFile.name + Harness.IO.newLine(); 1900 resultString = resultString + Harness.IO.newLine() + fileName + outputFile.text; 1901 } 1902 resultString += Harness.IO.newLine(); 1903 } 1904 1905 Harness.Baseline.runBaseline(ts.Debug.checkDefined(this.testData.globalOptions[MetadataOptionNames.baselineFile]), resultString); 1906 } 1907 1908 private flattenChainedMessage(diag: ts.DiagnosticMessageChain, indent = " ") { 1909 let result = ""; 1910 result += indent + diag.messageText + Harness.IO.newLine(); 1911 if (diag.next) { 1912 for (const kid of diag.next) { 1913 result += this.flattenChainedMessage(kid, indent + " "); 1914 } 1915 } 1916 return result; 1917 } 1918 1919 public baselineSyntacticDiagnostics() { 1920 const files = this.getCompilerTestFiles(); 1921 const result = this.getSyntacticDiagnosticBaselineText(files); 1922 Harness.Baseline.runBaseline(this.getBaselineFileNameForContainingTestFile(), result); 1923 } 1924 1925 private getCompilerTestFiles() { 1926 return ts.map(this.testData.files, ({ content, fileName }) => ({ 1927 content, unitName: fileName 1928 })); 1929 } 1930 1931 public baselineSyntacticAndSemanticDiagnostics() { 1932 const files = ts.filter(this.getCompilerTestFiles(), f => !ts.endsWith(f.unitName, ".json")); 1933 const result = this.getSyntacticDiagnosticBaselineText(files) 1934 + Harness.IO.newLine() 1935 + Harness.IO.newLine() 1936 + this.getSemanticDiagnosticBaselineText(files); 1937 Harness.Baseline.runBaseline(this.getBaselineFileNameForContainingTestFile(), result); 1938 } 1939 1940 private getSyntacticDiagnosticBaselineText(files: Harness.Compiler.TestFile[]) { 1941 const diagnostics = ts.flatMap(files, 1942 file => this.languageService.getSyntacticDiagnostics(file.unitName) 1943 ); 1944 const result = `Syntactic Diagnostics for file '${this.originalInputFileName}':` 1945 + Harness.IO.newLine() 1946 + Harness.Compiler.getErrorBaseline(files, diagnostics, /*pretty*/ false); 1947 return result; 1948 } 1949 1950 private getSemanticDiagnosticBaselineText(files: Harness.Compiler.TestFile[]) { 1951 const diagnostics = ts.flatMap(files, 1952 file => this.languageService.getSemanticDiagnostics(file.unitName) 1953 ); 1954 const result = `Semantic Diagnostics for file '${this.originalInputFileName}':` 1955 + Harness.IO.newLine() 1956 + Harness.Compiler.getErrorBaseline(files, diagnostics, /*pretty*/ false); 1957 return result; 1958 } 1959 1960 public baselineQuickInfo() { 1961 const baselineFile = this.getBaselineFileNameForContainingTestFile(); 1962 const result = ts.arrayFrom(this.testData.markerPositions.entries(), ([name, marker]) => ({ 1963 marker: { ...marker, name }, 1964 quickInfo: this.languageService.getQuickInfoAtPosition(marker.fileName, marker.position) 1965 })); 1966 Harness.Baseline.runBaseline(baselineFile, stringify(result)); 1967 } 1968 1969 public baselineSignatureHelp() { 1970 const baselineFile = this.getBaselineFileNameForContainingTestFile(); 1971 const result = ts.arrayFrom(this.testData.markerPositions.entries(), ([name, marker]) => ({ 1972 marker: { ...marker, name }, 1973 signatureHelp: this.languageService.getSignatureHelpItems(marker.fileName, marker.position, /*options*/ undefined) 1974 })); 1975 Harness.Baseline.runBaseline(baselineFile, stringify(result)); 1976 } 1977 1978 public baselineCompletions(preferences?: ts.UserPreferences) { 1979 const baselineFile = this.getBaselineFileNameForContainingTestFile(); 1980 const result = ts.arrayFrom(this.testData.markerPositions.entries(), ([name, marker]) => { 1981 this.goToMarker(marker); 1982 const completions = this.getCompletionListAtCaret(preferences); 1983 return { 1984 marker: { ...marker, name }, 1985 completionList: { 1986 ...completions, 1987 entries: completions?.entries.map(entry => ({ 1988 ...entry, 1989 ...this.getCompletionEntryDetails(entry.name, entry.source, entry.data, preferences) 1990 })), 1991 } 1992 }; 1993 }); 1994 Harness.Baseline.runBaseline(baselineFile, stringify(result)); 1995 } 1996 1997 public baselineSmartSelection() { 1998 const n = "\n"; 1999 const baselineFile = this.getBaselineFileNameForContainingTestFile(); 2000 const markers = this.getMarkers(); 2001 const fileContent = this.activeFile.content; 2002 const text = markers.map(marker => { 2003 const baselineContent = [fileContent.slice(0, marker.position) + "/**/" + fileContent.slice(marker.position) + n]; 2004 let selectionRange: ts.SelectionRange | undefined = this.languageService.getSmartSelectionRange(this.activeFile.fileName, marker.position); 2005 while (selectionRange) { 2006 const { textSpan } = selectionRange; 2007 let masked = Array.from(fileContent).map((char, index) => { 2008 const charCode = char.charCodeAt(0); 2009 if (index >= textSpan.start && index < ts.textSpanEnd(textSpan)) { 2010 return char === " " ? "•" : ts.isLineBreak(charCode) ? `↲${n}` : char; 2011 } 2012 return ts.isLineBreak(charCode) ? char : " "; 2013 }).join(""); 2014 masked = masked.replace(/^\s*$\r?\n?/gm, ""); // Remove blank lines 2015 const isRealCharacter = (char: string) => char !== "•" && char !== "↲" && !ts.isWhiteSpaceLike(char.charCodeAt(0)); 2016 const leadingWidth = Array.from(masked).findIndex(isRealCharacter); 2017 const trailingWidth = ts.findLastIndex(Array.from(masked), isRealCharacter); 2018 masked = masked.slice(0, leadingWidth) 2019 + masked.slice(leadingWidth, trailingWidth).replace(/•/g, " ").replace(/↲/g, "") 2020 + masked.slice(trailingWidth); 2021 baselineContent.push(masked); 2022 selectionRange = selectionRange.parent; 2023 } 2024 return baselineContent.join(fileContent.includes("\n") ? n + n : n); 2025 }).join(n.repeat(2) + "=".repeat(80) + n.repeat(2)); 2026 2027 Harness.Baseline.runBaseline(baselineFile, text); 2028 } 2029 2030 public printBreakpointLocation(pos: number) { 2031 Harness.IO.log("\n**Pos: " + pos + " " + this.spanInfoToString(this.getBreakpointStatementLocation(pos)!, " ")); 2032 } 2033 2034 public printBreakpointAtCurrentLocation() { 2035 this.printBreakpointLocation(this.currentCaretPosition); 2036 } 2037 2038 public printCurrentParameterHelp() { 2039 const help = this.languageService.getSignatureHelpItems(this.activeFile.fileName, this.currentCaretPosition, /*options*/ undefined); 2040 Harness.IO.log(stringify(help)); 2041 } 2042 2043 public printCurrentQuickInfo() { 2044 const quickInfo = this.languageService.getQuickInfoAtPosition(this.activeFile.fileName, this.currentCaretPosition)!; 2045 Harness.IO.log("Quick Info: " + quickInfo.displayParts!.map(part => part.text).join("")); 2046 } 2047 2048 public printErrorList() { 2049 const syntacticErrors = this.languageService.getSyntacticDiagnostics(this.activeFile.fileName); 2050 const semanticErrors = this.languageService.getSemanticDiagnostics(this.activeFile.fileName); 2051 const errorList = ts.concatenate(syntacticErrors, semanticErrors); 2052 Harness.IO.log(`Error list (${errorList.length} errors)`); 2053 2054 if (errorList.length) { 2055 errorList.forEach(err => { 2056 Harness.IO.log( 2057 "start: " + err.start + 2058 ", length: " + err.length + 2059 ", message: " + ts.flattenDiagnosticMessageText(err.messageText, Harness.IO.newLine())); 2060 }); 2061 } 2062 } 2063 2064 public printCurrentFileState(showWhitespace: boolean, makeCaretVisible: boolean) { 2065 for (const file of this.testData.files) { 2066 const active = (this.activeFile === file); 2067 Harness.IO.log(`=== Script (${file.fileName}) ${(active ? "(active, cursor at |)" : "")} ===`); 2068 let content = this.getFileContent(file.fileName); 2069 if (active) { 2070 content = content.substr(0, this.currentCaretPosition) + (makeCaretVisible ? "|" : "") + content.substr(this.currentCaretPosition); 2071 } 2072 if (showWhitespace) { 2073 content = makeWhitespaceVisible(content); 2074 } 2075 Harness.IO.log(content); 2076 } 2077 } 2078 2079 public printCurrentSignatureHelp() { 2080 const help = this.getSignatureHelp(ts.emptyOptions)!; 2081 Harness.IO.log(stringify(help.items[help.selectedItemIndex])); 2082 } 2083 2084 private getBaselineFileNameForInternalFourslashFile(ext = ".baseline") { 2085 return this.testData.globalOptions[MetadataOptionNames.baselineFile] || 2086 ts.getBaseFileName(this.activeFile.fileName).replace(ts.Extension.Ts, ext); 2087 } 2088 2089 private getBaselineFileNameForContainingTestFile(ext = ".baseline") { 2090 return this.testData.globalOptions[MetadataOptionNames.baselineFile] || 2091 ts.getBaseFileName(this.originalInputFileName).replace(ts.Extension.Ts, ext); 2092 } 2093 2094 private getSignatureHelp({ triggerReason }: FourSlashInterface.VerifySignatureHelpOptions): ts.SignatureHelpItems | undefined { 2095 return this.languageService.getSignatureHelpItems(this.activeFile.fileName, this.currentCaretPosition, { 2096 triggerReason 2097 }); 2098 } 2099 2100 public printCompletionListMembers(preferences: ts.UserPreferences | undefined) { 2101 const completions = this.getCompletionListAtCaret(preferences); 2102 this.printMembersOrCompletions(completions); 2103 } 2104 2105 private printMembersOrCompletions(info: ts.CompletionInfo | undefined) { 2106 if (info === undefined) return "No completion info."; 2107 const { entries } = info; 2108 2109 function pad(s: string, length: number) { 2110 return s + new Array(length - s.length + 1).join(" "); 2111 } 2112 function max<T>(arr: T[], selector: (x: T) => number): number { 2113 return arr.reduce((prev, x) => Math.max(prev, selector(x)), 0); 2114 } 2115 const longestNameLength = max(entries, m => m.name.length); 2116 const longestKindLength = max(entries, m => m.kind.length); 2117 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); 2118 const membersString = entries.map(m => `${pad(m.name, longestNameLength)} ${pad(m.kind, longestKindLength)} ${m.kindModifiers} ${m.isRecommended ? "recommended " : ""}${m.source === undefined ? "" : m.source}`).join("\n"); 2119 Harness.IO.log(membersString); 2120 } 2121 2122 public printContext() { 2123 ts.forEach(this.languageServiceAdapterHost.getFilenames(), Harness.IO.log); 2124 } 2125 2126 public deleteChar(count = 1) { 2127 let offset = this.currentCaretPosition; 2128 const ch = ""; 2129 2130 const checkCadence = (count >> 2) + 1; 2131 2132 for (let i = 0; i < count; i++) { 2133 this.editScriptAndUpdateMarkers(this.activeFile.fileName, offset, offset + 1, ch); 2134 2135 if (i % checkCadence === 0) { 2136 this.checkPostEditInvariants(); 2137 } 2138 2139 // Handle post-keystroke formatting 2140 if (this.enableFormatting) { 2141 const edits = this.languageService.getFormattingEditsAfterKeystroke(this.activeFile.fileName, offset, ch, this.formatCodeSettings); 2142 if (edits.length) { 2143 offset += this.applyEdits(this.activeFile.fileName, edits); 2144 } 2145 } 2146 } 2147 2148 this.checkPostEditInvariants(); 2149 } 2150 2151 public replace(start: number, length: number, text: string) { 2152 this.editScriptAndUpdateMarkers(this.activeFile.fileName, start, start + length, text); 2153 this.checkPostEditInvariants(); 2154 } 2155 2156 public deleteLineRange(startIndex: number, endIndexInclusive: number) { 2157 const startPos = this.languageServiceAdapterHost.lineAndCharacterToPosition(this.activeFile.fileName, { line: startIndex, character: 0 }); 2158 const endPos = this.languageServiceAdapterHost.lineAndCharacterToPosition(this.activeFile.fileName, { line: endIndexInclusive + 1, character: 0 }); 2159 this.replace(startPos, endPos - startPos, ""); 2160 } 2161 2162 public deleteCharBehindMarker(count = 1) { 2163 let offset = this.currentCaretPosition; 2164 const ch = ""; 2165 const checkCadence = (count >> 2) + 1; 2166 2167 for (let i = 0; i < count; i++) { 2168 this.currentCaretPosition--; 2169 offset--; 2170 this.editScriptAndUpdateMarkers(this.activeFile.fileName, offset, offset + 1, ch); 2171 2172 if (i % checkCadence === 0) { 2173 this.checkPostEditInvariants(); 2174 } 2175 2176 // Don't need to examine formatting because there are no formatting changes on backspace. 2177 } 2178 2179 this.checkPostEditInvariants(); 2180 } 2181 2182 // Enters lines of text at the current caret position 2183 public type(text: string, highFidelity = false) { 2184 let offset = this.currentCaretPosition; 2185 const prevChar = " "; 2186 const checkCadence = (text.length >> 2) + 1; 2187 const selection = this.getSelection(); 2188 this.replace(selection.pos, selection.end - selection.pos, ""); 2189 2190 for (let i = 0; i < text.length; i++) { 2191 const ch = text.charAt(i); 2192 this.editScriptAndUpdateMarkers(this.activeFile.fileName, offset, offset, ch); 2193 if (highFidelity) { 2194 this.languageService.getBraceMatchingAtPosition(this.activeFile.fileName, offset); 2195 } 2196 2197 this.currentCaretPosition++; 2198 offset++; 2199 2200 if (highFidelity) { 2201 if (ch === "(" || ch === "," || ch === "<") { 2202 /* Signature help*/ 2203 this.languageService.getSignatureHelpItems(this.activeFile.fileName, offset, { 2204 triggerReason: { 2205 kind: "characterTyped", 2206 triggerCharacter: ch 2207 } 2208 }); 2209 } 2210 else if (prevChar === " " && /A-Za-z_/.test(ch)) { 2211 /* Completions */ 2212 this.languageService.getCompletionsAtPosition(this.activeFile.fileName, offset, ts.emptyOptions); 2213 } 2214 2215 if (i % checkCadence === 0) { 2216 this.checkPostEditInvariants(); 2217 } 2218 } 2219 2220 // Handle post-keystroke formatting 2221 if (this.enableFormatting) { 2222 const edits = this.languageService.getFormattingEditsAfterKeystroke(this.activeFile.fileName, offset, ch, this.formatCodeSettings); 2223 if (edits.length) { 2224 offset += this.applyEdits(this.activeFile.fileName, edits); 2225 } 2226 } 2227 } 2228 2229 this.checkPostEditInvariants(); 2230 } 2231 2232 // Enters text as if the user had pasted it 2233 public paste(text: string) { 2234 const start = this.currentCaretPosition; 2235 this.editScriptAndUpdateMarkers(this.activeFile.fileName, this.currentCaretPosition, this.currentCaretPosition, text); 2236 this.checkPostEditInvariants(); 2237 const offset = this.currentCaretPosition += text.length; 2238 2239 // Handle formatting 2240 if (this.enableFormatting) { 2241 const edits = this.languageService.getFormattingEditsForRange(this.activeFile.fileName, start, offset, this.formatCodeSettings); 2242 if (edits.length) { 2243 this.applyEdits(this.activeFile.fileName, edits); 2244 } 2245 } 2246 2247 2248 this.checkPostEditInvariants(); 2249 } 2250 2251 private checkPostEditInvariants() { 2252 if (this.testType !== FourSlashTestType.Native) { 2253 // getSourcefile() results can not be serialized. Only perform these verifications 2254 // if running against a native LS object. 2255 return; 2256 } 2257 2258 const incrementalSourceFile = this.languageService.getNonBoundSourceFile(this.activeFile.fileName); 2259 Utils.assertInvariants(incrementalSourceFile, /*parent:*/ undefined); 2260 2261 const incrementalSyntaxDiagnostics = incrementalSourceFile.parseDiagnostics; 2262 2263 // Check syntactic structure 2264 const content = this.getFileContent(this.activeFile.fileName); 2265 2266 const options: ts.CreateSourceFileOptions = { 2267 languageVersion: ts.ScriptTarget.Latest, 2268 impliedNodeFormat: ts.getImpliedNodeFormatForFile( 2269 ts.toPath(this.activeFile.fileName, this.languageServiceAdapterHost.sys.getCurrentDirectory(), ts.hostGetCanonicalFileName(this.languageServiceAdapterHost)), 2270 /*cache*/ undefined, 2271 this.languageServiceAdapterHost, 2272 this.languageService.getProgram()?.getCompilerOptions() || {} 2273 ), 2274 setExternalModuleIndicator: ts.getSetExternalModuleIndicator(this.languageService.getProgram()?.getCompilerOptions() || {}), 2275 }; 2276 const referenceSourceFile = ts.createLanguageServiceSourceFile( 2277 this.activeFile.fileName, createScriptSnapShot(content), options, /*version:*/ "0", /*setNodeParents:*/ false); 2278 const referenceSyntaxDiagnostics = referenceSourceFile.parseDiagnostics; 2279 2280 Utils.assertDiagnosticsEquals(incrementalSyntaxDiagnostics, referenceSyntaxDiagnostics); 2281 Utils.assertStructuralEquals(incrementalSourceFile, referenceSourceFile); 2282 } 2283 2284 /** 2285 * @returns The number of characters added to the file as a result of the edits. 2286 * May be negative. 2287 */ 2288 private applyEdits(fileName: string, edits: readonly ts.TextChange[]): number { 2289 let runningOffset = 0; 2290 2291 forEachTextChange(edits, edit => { 2292 const offsetStart = edit.span.start; 2293 const offsetEnd = offsetStart + edit.span.length; 2294 this.editScriptAndUpdateMarkers(fileName, offsetStart, offsetEnd, edit.newText); 2295 const editDelta = edit.newText.length - edit.span.length; 2296 if (offsetStart <= this.currentCaretPosition) { 2297 if (offsetEnd <= this.currentCaretPosition) { 2298 // The entirety of the edit span falls before the caret position, shift the caret accordingly 2299 this.currentCaretPosition += editDelta; 2300 } 2301 else { 2302 // The span being replaced includes the caret position, place the caret at the beginning of the span 2303 this.currentCaretPosition = offsetStart; 2304 } 2305 } 2306 runningOffset += editDelta; 2307 }); 2308 2309 return runningOffset; 2310 } 2311 2312 public copyFormatOptions(): ts.FormatCodeSettings { 2313 return ts.clone(this.formatCodeSettings); 2314 } 2315 2316 public setFormatOptions(formatCodeOptions: ts.FormatCodeOptions | ts.FormatCodeSettings): ts.FormatCodeSettings { 2317 const oldFormatCodeOptions = this.formatCodeSettings; 2318 this.formatCodeSettings = ts.toEditorSettings(formatCodeOptions); 2319 if (this.testType === FourSlashTestType.Server) { 2320 (this.languageService as ts.server.SessionClient).setFormattingOptions(this.formatCodeSettings); 2321 } 2322 return oldFormatCodeOptions; 2323 } 2324 2325 public formatDocument() { 2326 const edits = this.languageService.getFormattingEditsForDocument(this.activeFile.fileName, this.formatCodeSettings); 2327 this.applyEdits(this.activeFile.fileName, edits); 2328 } 2329 2330 public formatSelection(start: number, end: number) { 2331 const edits = this.languageService.getFormattingEditsForRange(this.activeFile.fileName, start, end, this.formatCodeSettings); 2332 this.applyEdits(this.activeFile.fileName, edits); 2333 } 2334 2335 public formatOnType(pos: number, key: string) { 2336 const edits = this.languageService.getFormattingEditsAfterKeystroke(this.activeFile.fileName, pos, key, this.formatCodeSettings); 2337 this.applyEdits(this.activeFile.fileName, edits); 2338 } 2339 2340 private editScriptAndUpdateMarkers(fileName: string, editStart: number, editEnd: number, newText: string) { 2341 this.languageServiceAdapterHost.editScript(fileName, editStart, editEnd, newText); 2342 if (this.assertTextConsistent) { 2343 this.assertTextConsistent(fileName); 2344 } 2345 for (const marker of this.testData.markers) { 2346 if (marker.fileName === fileName) { 2347 marker.position = updatePosition(marker.position, editStart, editEnd, newText); 2348 } 2349 } 2350 2351 for (const range of this.testData.ranges) { 2352 if (range.fileName === fileName) { 2353 range.pos = updatePosition(range.pos, editStart, editEnd, newText); 2354 range.end = updatePosition(range.end, editStart, editEnd, newText); 2355 } 2356 } 2357 this.testData.rangesByText = undefined; 2358 } 2359 2360 private removeWhitespace(text: string): string { 2361 return text.replace(/\s/g, ""); 2362 } 2363 2364 public goToBOF() { 2365 this.goToPosition(0); 2366 } 2367 2368 public goToEOF() { 2369 const len = this.getFileContent(this.activeFile.fileName).length; 2370 this.goToPosition(len); 2371 } 2372 2373 public goToRangeStart({ fileName, pos }: Range) { 2374 this.openFile(fileName); 2375 this.goToPosition(pos); 2376 } 2377 2378 public goToTypeDefinition(definitionIndex: number) { 2379 const definitions = this.languageService.getTypeDefinitionAtPosition(this.activeFile.fileName, this.currentCaretPosition)!; 2380 if (!definitions || !definitions.length) { 2381 this.raiseError("goToTypeDefinition failed - expected to find at least one definition location but got 0"); 2382 } 2383 2384 if (definitionIndex >= definitions.length) { 2385 this.raiseError(`goToTypeDefinition failed - definitionIndex value (${definitionIndex}) exceeds definition list size (${definitions.length})`); 2386 } 2387 2388 const definition = definitions[definitionIndex]; 2389 this.openFile(definition.fileName); 2390 this.currentCaretPosition = definition.textSpan.start; 2391 } 2392 2393 public verifyTypeDefinitionsCount(negative: boolean, expectedCount: number) { 2394 const assertFn = negative ? assert.notEqual : assert.equal; 2395 2396 const definitions = this.languageService.getTypeDefinitionAtPosition(this.activeFile.fileName, this.currentCaretPosition); 2397 const actualCount = definitions && definitions.length || 0; 2398 2399 assertFn(actualCount, expectedCount, this.messageAtLastKnownMarker("Type definitions Count")); 2400 } 2401 2402 public verifyImplementationListIsEmpty(negative: boolean) { 2403 const implementations = this.languageService.getImplementationAtPosition(this.activeFile.fileName, this.currentCaretPosition); 2404 2405 if (negative) { 2406 assert.isTrue(implementations && implementations.length > 0, "Expected at least one implementation but got 0"); 2407 } 2408 else { 2409 assert.isUndefined(implementations, "Expected implementation list to be empty but implementations returned"); 2410 } 2411 } 2412 2413 public verifyGoToDefinitionName(expectedName: string, expectedContainerName: string) { 2414 const definitions = this.languageService.getDefinitionAtPosition(this.activeFile.fileName, this.currentCaretPosition); 2415 const actualDefinitionName = definitions && definitions.length ? definitions[0].name : ""; 2416 const actualDefinitionContainerName = definitions && definitions.length ? definitions[0].containerName : ""; 2417 assert.equal(actualDefinitionName, expectedName, this.messageAtLastKnownMarker("Definition Info Name")); 2418 assert.equal(actualDefinitionContainerName, expectedContainerName, this.messageAtLastKnownMarker("Definition Info Container Name")); 2419 } 2420 2421 public goToImplementation() { 2422 const implementations = this.languageService.getImplementationAtPosition(this.activeFile.fileName, this.currentCaretPosition)!; 2423 if (!implementations || !implementations.length) { 2424 this.raiseError("goToImplementation failed - expected to find at least one implementation location but got 0"); 2425 } 2426 if (implementations.length > 1) { 2427 this.raiseError(`goToImplementation failed - more than 1 implementation returned (${implementations.length})`); 2428 } 2429 2430 const implementation = implementations[0]; 2431 this.openFile(implementation.fileName); 2432 this.currentCaretPosition = implementation.textSpan.start; 2433 } 2434 2435 public verifyRangesInImplementationList(markerName: string) { 2436 this.goToMarker(markerName); 2437 const implementations: readonly ImplementationLocationInformation[] = this.languageService.getImplementationAtPosition(this.activeFile.fileName, this.currentCaretPosition)!; 2438 if (!implementations || !implementations.length) { 2439 this.raiseError("verifyRangesInImplementationList failed - expected to find at least one implementation location but got 0"); 2440 } 2441 2442 const duplicate = findDuplicatedElement(implementations, ts.documentSpansEqual); 2443 if (duplicate) { 2444 const { textSpan, fileName } = duplicate; 2445 this.raiseError(`Duplicate implementations returned for range (${textSpan.start}, ${ts.textSpanEnd(textSpan)}) in ${fileName}`); 2446 } 2447 2448 const ranges = this.getRanges(); 2449 2450 if (!ranges || !ranges.length) { 2451 this.raiseError("verifyRangesInImplementationList failed - expected to find at least one range in test source"); 2452 } 2453 2454 const unsatisfiedRanges: Range[] = []; 2455 2456 const delayedErrors: string[] = []; 2457 for (const range of ranges) { 2458 const length = range.end - range.pos; 2459 const matchingImpl = ts.find(implementations, impl => 2460 range.fileName === impl.fileName && range.pos === impl.textSpan.start && length === impl.textSpan.length); 2461 if (matchingImpl) { 2462 if (range.marker && range.marker.data) { 2463 const expected = range.marker.data as { displayParts?: ts.SymbolDisplayPart[], parts: string[], kind?: string }; 2464 if (expected.displayParts) { 2465 if (!ts.arrayIsEqualTo(expected.displayParts, matchingImpl.displayParts, displayPartIsEqualTo)) { 2466 delayedErrors.push(`Mismatched display parts: expected ${JSON.stringify(expected.displayParts)}, actual ${JSON.stringify(matchingImpl.displayParts)}`); 2467 } 2468 } 2469 else if (expected.parts) { 2470 const actualParts = matchingImpl.displayParts.map(p => p.text); 2471 if (!ts.arrayIsEqualTo(expected.parts, actualParts)) { 2472 delayedErrors.push(`Mismatched non-tagged display parts: expected ${JSON.stringify(expected.parts)}, actual ${JSON.stringify(actualParts)}`); 2473 } 2474 } 2475 if (expected.kind !== undefined) { 2476 if (expected.kind !== matchingImpl.kind) { 2477 delayedErrors.push(`Mismatched kind: expected ${JSON.stringify(expected.kind)}, actual ${JSON.stringify(matchingImpl.kind)}`); 2478 } 2479 } 2480 } 2481 2482 matchingImpl.matched = true; 2483 } 2484 else { 2485 unsatisfiedRanges.push(range); 2486 } 2487 } 2488 if (delayedErrors.length) { 2489 this.raiseError(delayedErrors.join("\n")); 2490 } 2491 2492 const unmatchedImplementations = implementations.filter(impl => !impl.matched); 2493 if (unmatchedImplementations.length || unsatisfiedRanges.length) { 2494 let error = "Not all ranges or implementations are satisfied"; 2495 if (unsatisfiedRanges.length) { 2496 error += "\nUnsatisfied ranges:"; 2497 for (const range of unsatisfiedRanges) { 2498 error += `\n (${range.pos}, ${range.end}) in ${range.fileName}: ${this.rangeText(range)}`; 2499 } 2500 } 2501 2502 if (unmatchedImplementations.length) { 2503 error += "\nUnmatched implementations:"; 2504 for (const impl of unmatchedImplementations) { 2505 const end = impl.textSpan.start + impl.textSpan.length; 2506 error += `\n (${impl.textSpan.start}, ${end}) in ${impl.fileName}: ${this.getFileContent(impl.fileName).slice(impl.textSpan.start, end)}`; 2507 } 2508 } 2509 this.raiseError(error); 2510 } 2511 2512 function displayPartIsEqualTo(a: ts.SymbolDisplayPart, b: ts.SymbolDisplayPart): boolean { 2513 return a.kind === b.kind && a.text === b.text; 2514 } 2515 } 2516 2517 public getMarkers(): Marker[] { 2518 // Return a copy of the list 2519 return this.testData.markers.slice(0); 2520 } 2521 2522 public getMarkerNames(): string[] { 2523 return ts.arrayFrom(this.testData.markerPositions.keys()); 2524 } 2525 2526 public getRanges(): Range[] { 2527 return this.testData.ranges; 2528 } 2529 2530 public getRangesInFile(fileName = this.activeFile.fileName) { 2531 return this.getRanges().filter(r => r.fileName === fileName); 2532 } 2533 2534 public rangesByText(): ts.ESMap<string, Range[]> { 2535 if (this.testData.rangesByText) return this.testData.rangesByText; 2536 const result = ts.createMultiMap<Range>(); 2537 this.testData.rangesByText = result; 2538 for (const range of this.getRanges()) { 2539 const text = this.rangeText(range); 2540 result.add(text, range); 2541 } 2542 return result; 2543 } 2544 2545 private rangeText({ fileName, pos, end }: Range): string { 2546 return this.getFileContent(fileName).slice(pos, end); 2547 } 2548 2549 public verifyCaretAtMarker(markerName = "") { 2550 const pos = this.getMarkerByName(markerName); 2551 if (pos.fileName !== this.activeFile.fileName) { 2552 throw new Error(`verifyCaretAtMarker failed - expected to be in file "${pos.fileName}", but was in file "${this.activeFile.fileName}"`); 2553 } 2554 if (pos.position !== this.currentCaretPosition) { 2555 throw new Error(`verifyCaretAtMarker failed - expected to be at marker "/*${markerName}*/, but was at position ${this.currentCaretPosition}(${this.getLineColStringAtPosition(this.currentCaretPosition)})`); 2556 } 2557 } 2558 2559 private getIndentation(fileName: string, position: number, indentStyle: ts.IndentStyle, baseIndentSize: number): number { 2560 const formatOptions = ts.clone(this.formatCodeSettings); 2561 formatOptions.indentStyle = indentStyle; 2562 formatOptions.baseIndentSize = baseIndentSize; 2563 return this.languageService.getIndentationAtPosition(fileName, position, formatOptions); 2564 } 2565 2566 public verifyIndentationAtCurrentPosition(numberOfSpaces: number, indentStyle: ts.IndentStyle = ts.IndentStyle.Smart, baseIndentSize = 0) { 2567 const actual = this.getIndentation(this.activeFile.fileName, this.currentCaretPosition, indentStyle, baseIndentSize); 2568 const lineCol = this.getLineColStringAtPosition(this.currentCaretPosition); 2569 if (actual !== numberOfSpaces) { 2570 this.raiseError(`verifyIndentationAtCurrentPosition failed at ${lineCol} - expected: ${numberOfSpaces}, actual: ${actual}`); 2571 } 2572 } 2573 2574 public verifyIndentationAtPosition(fileName: string, position: number, numberOfSpaces: number, indentStyle: ts.IndentStyle = ts.IndentStyle.Smart, baseIndentSize = 0) { 2575 const actual = this.getIndentation(fileName, position, indentStyle, baseIndentSize); 2576 const lineCol = this.getLineColStringAtPosition(position); 2577 if (actual !== numberOfSpaces) { 2578 this.raiseError(`verifyIndentationAtPosition failed at ${lineCol} - expected: ${numberOfSpaces}, actual: ${actual}`); 2579 } 2580 } 2581 2582 public verifyCurrentLineContent(text: string) { 2583 const actual = this.getCurrentLineContent(); 2584 if (actual !== text) { 2585 throw new Error("verifyCurrentLineContent\n" + displayExpectedAndActualString(text, actual, /* quoted */ true)); 2586 } 2587 } 2588 2589 public verifyCurrentFileContent(text: string) { 2590 this.verifyFileContent(this.activeFile.fileName, text); 2591 } 2592 2593 private verifyFileContent(fileName: string, text: string) { 2594 const actual = this.getFileContent(fileName); 2595 if (actual !== text) { 2596 throw new Error(`verifyFileContent failed:\n${showTextDiff(text, actual)}`); 2597 } 2598 } 2599 2600 public verifyFormatDocumentChangesNothing(): void { 2601 const { fileName } = this.activeFile; 2602 const before = this.getFileContent(fileName); 2603 this.formatDocument(); 2604 this.verifyFileContent(fileName, before); 2605 } 2606 2607 public verifyTextAtCaretIs(text: string) { 2608 const actual = this.getFileContent(this.activeFile.fileName).substring(this.currentCaretPosition, this.currentCaretPosition + text.length); 2609 if (actual !== text) { 2610 throw new Error("verifyTextAtCaretIs\n" + displayExpectedAndActualString(text, actual, /* quoted */ true)); 2611 } 2612 } 2613 2614 public verifyCurrentNameOrDottedNameSpanText(text: string) { 2615 const span = this.languageService.getNameOrDottedNameSpan(this.activeFile.fileName, this.currentCaretPosition, this.currentCaretPosition); 2616 if (!span) { 2617 return this.raiseError("verifyCurrentNameOrDottedNameSpanText\n" + displayExpectedAndActualString("\"" + text + "\"", "undefined")); 2618 } 2619 2620 const actual = this.getFileContent(this.activeFile.fileName).substring(span.start, ts.textSpanEnd(span)); 2621 if (actual !== text) { 2622 this.raiseError("verifyCurrentNameOrDottedNameSpanText\n" + displayExpectedAndActualString(text, actual, /* quoted */ true)); 2623 } 2624 } 2625 2626 private getNameOrDottedNameSpan(pos: number) { 2627 return this.languageService.getNameOrDottedNameSpan(this.activeFile.fileName, pos, pos); 2628 } 2629 2630 public baselineCurrentFileNameOrDottedNameSpans() { 2631 Harness.Baseline.runBaseline( 2632 this.testData.globalOptions[MetadataOptionNames.baselineFile], 2633 this.baselineCurrentFileLocations(pos => this.getNameOrDottedNameSpan(pos)!)); 2634 } 2635 2636 public printNameOrDottedNameSpans(pos: number) { 2637 Harness.IO.log(this.spanInfoToString(this.getNameOrDottedNameSpan(pos)!, "**")); 2638 } 2639 2640 private classificationToIdentifier(classification: number) { 2641 2642 const tokenTypes: string[] = []; 2643 tokenTypes[ts.classifier.v2020.TokenType.class] = "class"; 2644 tokenTypes[ts.classifier.v2020.TokenType.enum] = "enum"; 2645 tokenTypes[ts.classifier.v2020.TokenType.interface] = "interface"; 2646 tokenTypes[ts.classifier.v2020.TokenType.namespace] = "namespace"; 2647 tokenTypes[ts.classifier.v2020.TokenType.typeParameter] = "typeParameter"; 2648 tokenTypes[ts.classifier.v2020.TokenType.type] = "type"; 2649 tokenTypes[ts.classifier.v2020.TokenType.parameter] = "parameter"; 2650 tokenTypes[ts.classifier.v2020.TokenType.variable] = "variable"; 2651 tokenTypes[ts.classifier.v2020.TokenType.enumMember] = "enumMember"; 2652 tokenTypes[ts.classifier.v2020.TokenType.property] = "property"; 2653 tokenTypes[ts.classifier.v2020.TokenType.function] = "function"; 2654 tokenTypes[ts.classifier.v2020.TokenType.member] = "member"; 2655 2656 const tokenModifiers: string[] = []; 2657 tokenModifiers[ts.classifier.v2020.TokenModifier.async] = "async"; 2658 tokenModifiers[ts.classifier.v2020.TokenModifier.declaration] = "declaration"; 2659 tokenModifiers[ts.classifier.v2020.TokenModifier.readonly] = "readonly"; 2660 tokenModifiers[ts.classifier.v2020.TokenModifier.static] = "static"; 2661 tokenModifiers[ts.classifier.v2020.TokenModifier.local] = "local"; 2662 tokenModifiers[ts.classifier.v2020.TokenModifier.defaultLibrary] = "defaultLibrary"; 2663 2664 2665 function getTokenTypeFromClassification(tsClassification: number): number | undefined { 2666 if (tsClassification > ts.classifier.v2020.TokenEncodingConsts.modifierMask) { 2667 return (tsClassification >> ts.classifier.v2020.TokenEncodingConsts.typeOffset) - 1; 2668 } 2669 return undefined; 2670 } 2671 2672 function getTokenModifierFromClassification(tsClassification: number) { 2673 return tsClassification & ts.classifier.v2020.TokenEncodingConsts.modifierMask; 2674 } 2675 2676 const typeIdx = getTokenTypeFromClassification(classification) || 0; 2677 const modSet = getTokenModifierFromClassification(classification); 2678 2679 return [tokenTypes[typeIdx], ...tokenModifiers.filter((_, i) => modSet & 1 << i)].join("."); 2680 } 2681 2682 private verifyClassifications(expected: { classificationType: string | number, text?: string; textSpan?: TextSpan }[], actual: (ts.ClassifiedSpan | ts.ClassifiedSpan2020)[] , sourceFileText: string) { 2683 if (actual.length !== expected.length) { 2684 this.raiseError("verifyClassifications failed - expected total classifications to be " + expected.length + 2685 ", but was " + actual.length + 2686 jsonMismatchString()); 2687 } 2688 2689 ts.zipWith(expected, actual, (expectedClassification, actualClassification) => { 2690 const expectedType = expectedClassification.classificationType; 2691 const actualType = typeof actualClassification.classificationType === "number" ? this.classificationToIdentifier(actualClassification.classificationType) : actualClassification.classificationType; 2692 2693 if (expectedType !== actualType) { 2694 this.raiseError("verifyClassifications failed - expected classifications type to be " + 2695 expectedType + ", but was " + 2696 actualType + 2697 jsonMismatchString()); 2698 } 2699 2700 const expectedSpan = expectedClassification.textSpan; 2701 const actualSpan = actualClassification.textSpan; 2702 2703 if (expectedSpan) { 2704 const expectedLength = expectedSpan.end - expectedSpan.start; 2705 2706 if (expectedSpan.start !== actualSpan.start || expectedLength !== actualSpan.length) { 2707 this.raiseError("verifyClassifications failed - expected span of text to be " + 2708 "{start=" + expectedSpan.start + ", length=" + expectedLength + "}, but was " + 2709 "{start=" + actualSpan.start + ", length=" + actualSpan.length + "}" + 2710 jsonMismatchString()); 2711 } 2712 } 2713 2714 const actualText = this.activeFile.content.substr(actualSpan.start, actualSpan.length); 2715 if (expectedClassification.text !== actualText) { 2716 this.raiseError("verifyClassifications failed - expected classified text to be " + 2717 expectedClassification.text + ", but was " + 2718 actualText + 2719 jsonMismatchString()); 2720 } 2721 }); 2722 2723 function jsonMismatchString() { 2724 const showActual = actual.map(({ classificationType, textSpan }) => 2725 ({ classificationType, text: sourceFileText.slice(textSpan.start, textSpan.start + textSpan.length) })); 2726 return Harness.IO.newLine() + 2727 "expected: '" + Harness.IO.newLine() + stringify(expected) + "'" + Harness.IO.newLine() + 2728 "actual: '" + Harness.IO.newLine() + stringify(showActual) + "'"; 2729 } 2730 } 2731 2732 public verifyProjectInfo(expected: string[]) { 2733 if (this.testType === FourSlashTestType.Server) { 2734 const actual = (this.languageService as ts.server.SessionClient).getProjectInfo( 2735 this.activeFile.fileName, 2736 /* needFileNameList */ true 2737 ); 2738 assert.equal( 2739 expected.join(","), 2740 actual.fileNames!.map(file => { 2741 return file.replace(this.basePath + "/", ""); 2742 }).join(",") 2743 ); 2744 } 2745 } 2746 2747 public replaceWithSemanticClassifications(format: ts.SemanticClassificationFormat.TwentyTwenty) { 2748 const actual = this.languageService.getSemanticClassifications(this.activeFile.fileName, 2749 ts.createTextSpan(0, this.activeFile.content.length), format); 2750 const replacement = [`const c2 = classification("2020");`,`verify.semanticClassificationsAre("2020",`]; 2751 for (const a of actual) { 2752 const identifier = this.classificationToIdentifier(a.classificationType as number); 2753 const text = this.activeFile.content.slice(a.textSpan.start, a.textSpan.start + a.textSpan.length); 2754 replacement.push(` c2.semanticToken("${identifier}", "${text}"), `); 2755 } 2756 replacement.push(");"); 2757 2758 throw new Error("You need to change the source code of fourslash test to use replaceWithSemanticClassifications"); 2759 2760 // const fs = require("fs"); 2761 // const testfilePath = this.originalInputFileName.slice(1); 2762 // const testfile = fs.readFileSync(testfilePath, "utf8"); 2763 // const newfile = testfile.replace("verify.replaceWithSemanticClassifications(\"2020\")", replacement.join("\n")); 2764 // fs.writeFileSync(testfilePath, newfile); 2765 } 2766 2767 public verifyEncodedSyntacticClassificationsLength(expected: number) { 2768 const actual = this.languageService.getEncodedSyntacticClassifications(this.activeFile.fileName, ts.createTextSpan(0, this.activeFile.content.length)); 2769 if (actual.spans.length !== expected) { 2770 this.raiseError(`encodedSyntacticClassificationsLength failed - expected total spans to be ${expected} got ${actual.spans.length}`); 2771 } 2772 } 2773 2774 public verifyEncodedSemanticClassificationsLength(format: ts.SemanticClassificationFormat, expected: number) { 2775 const actual = this.languageService.getEncodedSemanticClassifications(this.activeFile.fileName, ts.createTextSpan(0, this.activeFile.content.length), format); 2776 if (actual.spans.length !== expected) { 2777 this.raiseError(`encodedSemanticClassificationsLength failed - expected total spans to be ${expected} got ${actual.spans.length}`); 2778 } 2779 } 2780 2781 public verifySemanticClassifications(format: ts.SemanticClassificationFormat, expected: { classificationType: string | number; text?: string }[]) { 2782 const actual = this.languageService.getSemanticClassifications(this.activeFile.fileName, 2783 ts.createTextSpan(0, this.activeFile.content.length), format); 2784 this.verifyClassifications(expected, actual, this.activeFile.content); 2785 } 2786 2787 public verifySyntacticClassifications(expected: { classificationType: string; text: string }[]) { 2788 const actual = this.languageService.getSyntacticClassifications(this.activeFile.fileName, 2789 ts.createTextSpan(0, this.activeFile.content.length)); 2790 2791 this.verifyClassifications(expected, actual, this.activeFile.content); 2792 } 2793 2794 public printOutliningSpans() { 2795 const spans = this.languageService.getOutliningSpans(this.activeFile.fileName); 2796 Harness.IO.log(`Outlining spans (${spans.length} items)\nResults:`); 2797 Harness.IO.log(stringify(spans)); 2798 this.printOutliningSpansInline(spans); 2799 } 2800 2801 private printOutliningSpansInline(spans: ts.OutliningSpan[]) { 2802 const allSpanInsets = [] as { text: string, pos: number }[]; 2803 let annotated = this.activeFile.content; 2804 ts.forEach(spans, span => { 2805 allSpanInsets.push({ text: "[|", pos: span.textSpan.start }); 2806 allSpanInsets.push({ text: "|]", pos: span.textSpan.start + span.textSpan.length }); 2807 }); 2808 2809 const reverseSpans = allSpanInsets.sort((l, r) => r.pos - l.pos); 2810 ts.forEach(reverseSpans, span => { 2811 annotated = annotated.slice(0, span.pos) + span.text + annotated.slice(span.pos); 2812 }); 2813 Harness.IO.log(`\nMockup:\n${annotated}`); 2814 } 2815 2816 public verifyOutliningSpans(spans: Range[], kind?: "comment" | "region" | "code" | "imports") { 2817 const actual = this.languageService.getOutliningSpans(this.activeFile.fileName); 2818 2819 const filterActual = ts.filter(actual, f => kind === undefined ? true : f.kind === kind); 2820 if (filterActual.length !== spans.length) { 2821 this.raiseError(`verifyOutliningSpans failed - expected total spans to be ${spans.length}, but was ${actual.length}\n\nFound Spans:\n\n${this.printOutliningSpansInline(actual)}`); 2822 } 2823 2824 ts.zipWith(spans, filterActual, (expectedSpan, actualSpan, i) => { 2825 if (expectedSpan.pos !== actualSpan.textSpan.start || expectedSpan.end !== ts.textSpanEnd(actualSpan.textSpan)) { 2826 return this.raiseError(`verifyOutliningSpans failed - span ${(i + 1)} expected: (${expectedSpan.pos},${expectedSpan.end}), actual: (${actualSpan.textSpan.start},${ts.textSpanEnd(actualSpan.textSpan)})`); 2827 } 2828 if (kind !== undefined && actualSpan.kind !== kind) { 2829 return this.raiseError(`verifyOutliningSpans failed - span ${(i + 1)} expected kind: ('${kind}'), actual: ('${actualSpan.kind}')`); 2830 } 2831 }); 2832 } 2833 2834 public verifyOutliningHintSpans(spans: Range[]) { 2835 const actual = this.languageService.getOutliningSpans(this.activeFile.fileName); 2836 2837 if (actual.length !== spans.length) { 2838 this.raiseError(`verifyOutliningHintSpans failed - expected total spans to be ${spans.length}, but was ${actual.length}`); 2839 } 2840 2841 ts.zipWith(spans, actual, (expectedSpan, actualSpan, i) => { 2842 if (expectedSpan.pos !== actualSpan.hintSpan.start || expectedSpan.end !== ts.textSpanEnd(actualSpan.hintSpan)) { 2843 return this.raiseError(`verifyOutliningSpans failed - span ${(i + 1)} expected: (${expectedSpan.pos},${expectedSpan.end}), actual: (${actualSpan.hintSpan.start},${ts.textSpanEnd(actualSpan.hintSpan)})`); 2844 } 2845 }); 2846 } 2847 2848 public verifyTodoComments(descriptors: string[], spans: Range[]) { 2849 const actual = this.languageService.getTodoComments(this.activeFile.fileName, 2850 descriptors.map(d => ({ text: d, priority: 0 }))); 2851 2852 if (actual.length !== spans.length) { 2853 this.raiseError(`verifyTodoComments failed - expected total spans to be ${spans.length}, but was ${actual.length}`); 2854 } 2855 2856 ts.zipWith(spans, actual, (expectedSpan, actualComment, i) => { 2857 const actualCommentSpan = ts.createTextSpan(actualComment.position, actualComment.message.length); 2858 2859 if (expectedSpan.pos !== actualCommentSpan.start || expectedSpan.end !== ts.textSpanEnd(actualCommentSpan)) { 2860 this.raiseError(`verifyOutliningSpans failed - span ${(i + 1)} expected: (${expectedSpan.pos},${expectedSpan.end}), actual: (${actualCommentSpan.start},${ts.textSpanEnd(actualCommentSpan)})`); 2861 } 2862 }); 2863 } 2864 2865 /** 2866 * Finds and applies a code action corresponding to the supplied parameters. 2867 * If index is undefined, applies the unique code action available. 2868 * @param errorCode The error code that generated the code action. 2869 * @param index The nth (0-index-based) codeaction available generated by errorCode. 2870 */ 2871 public getAndApplyCodeActions(errorCode?: number, index?: number) { 2872 const fileName = this.activeFile.fileName; 2873 const fixes = this.getCodeFixes(fileName, errorCode); 2874 if (index === undefined) { 2875 if (!(fixes && fixes.length === 1)) { 2876 this.raiseError(`Should find exactly one codefix, but ${fixes ? fixes.length : "none"} found. ${fixes ? fixes.map(a => `${Harness.IO.newLine()} "${a.description}"`) : ""}`); 2877 } 2878 index = 0; 2879 } 2880 else { 2881 if (!(fixes && fixes.length >= index + 1)) { 2882 this.raiseError(`Should find at least ${index + 1} codefix(es), but ${fixes ? fixes.length : "none"} found.`); 2883 } 2884 } 2885 2886 this.applyChanges(fixes[index].changes); 2887 } 2888 2889 public applyCodeActionFromCompletion(markerName: string, options: FourSlashInterface.VerifyCompletionActionOptions) { 2890 this.goToMarker(markerName); 2891 2892 const details = this.getCompletionEntryDetails(options.name, options.source, options.data, options.preferences); 2893 if (!details) { 2894 const completions = this.getCompletionListAtCaret(options.preferences)?.entries; 2895 const matchingName = completions?.filter(e => e.name === options.name); 2896 const detailMessage = matchingName?.length 2897 ? `\n Found ${matchingName.length} with name '${options.name}' from source(s) ${matchingName.map(e => `'${e.source}'`).join(", ")}.` 2898 : ` (In fact, there were no completions with name '${options.name}' at all.)`; 2899 return this.raiseError(`No completions were found for the given name, source/data, and preferences.` + detailMessage); 2900 } 2901 const codeActions = details.codeActions; 2902 if (codeActions?.length !== 1) { 2903 this.raiseError(`Expected one code action, got ${codeActions?.length ?? 0}`); 2904 } 2905 const codeAction = ts.first(codeActions); 2906 2907 if (codeAction.description !== options.description) { 2908 this.raiseError(`Expected description to be:\n${options.description}\ngot:\n${codeActions[0].description}`); 2909 } 2910 this.applyChanges(codeAction.changes); 2911 2912 this.verifyNewContentAfterChange(options, ts.flatMap(codeActions, a => a.changes.map(c => c.fileName))); 2913 } 2914 2915 public verifyRangeIs(expectedText: string, includeWhiteSpace?: boolean) { 2916 this.verifyTextMatches(this.rangeText(this.getOnlyRange()), !!includeWhiteSpace, expectedText); 2917 } 2918 2919 private getOnlyRange() { 2920 const ranges = this.getRanges(); 2921 if (ranges.length !== 1) { 2922 this.raiseError("Exactly one range should be specified in the testfile."); 2923 } 2924 return ts.first(ranges); 2925 } 2926 2927 private verifyTextMatches(actualText: string, includeWhitespace: boolean, expectedText: string) { 2928 const removeWhitespace = (s: string): string => includeWhitespace ? s : this.removeWhitespace(s); 2929 if (removeWhitespace(actualText) !== removeWhitespace(expectedText)) { 2930 this.raiseError(`Actual range text doesn't match expected text.\n${showTextDiff(expectedText, actualText)}`); 2931 } 2932 } 2933 2934 /** 2935 * Compares expected text to the text that would be in the sole range 2936 * (ie: [|...|]) in the file after applying the codefix sole codefix 2937 * in the source file. 2938 */ 2939 public verifyRangeAfterCodeFix(expectedText: string, includeWhiteSpace?: boolean, errorCode?: number, index?: number) { 2940 this.getAndApplyCodeActions(errorCode, index); 2941 this.verifyRangeIs(expectedText, includeWhiteSpace); 2942 } 2943 2944 public verifyCodeFixAll({ fixId, fixAllDescription, newFileContent, commands: expectedCommands }: FourSlashInterface.VerifyCodeFixAllOptions): void { 2945 const fixWithId = ts.find(this.getCodeFixes(this.activeFile.fileName), a => a.fixId === fixId); 2946 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.", () => 2947 `Expected '${fixId}'. Available actions:\n${ts.mapDefined(this.getCodeFixes(this.activeFile.fileName), a => `${a.fixName} (${a.fixId || "no fix id"})`).join("\n")}`); 2948 ts.Debug.assertEqual(fixWithId.fixAllDescription, fixAllDescription); 2949 2950 const { changes, commands } = this.languageService.getCombinedCodeFix({ type: "file", fileName: this.activeFile.fileName }, fixId, this.formatCodeSettings, ts.emptyOptions); 2951 assert.deepEqual<readonly {}[] | undefined>(commands, expectedCommands); 2952 this.verifyNewContent({ newFileContent }, changes); 2953 } 2954 2955 public verifyCodeFix(options: FourSlashInterface.VerifyCodeFixOptions) { 2956 const fileName = this.activeFile.fileName; 2957 const actions = this.getCodeFixes(fileName, options.errorCode, options.preferences); 2958 let index = options.index; 2959 if (index === undefined) { 2960 if (!(actions && actions.length === 1)) { 2961 this.raiseError(`Should find exactly one codefix, but ${actions ? actions.length : "none"} found. ${actions ? actions.map(a => `${Harness.IO.newLine()} "${a.description}"`) : ""}`); 2962 } 2963 index = 0; 2964 } 2965 else { 2966 if (!(actions && actions.length >= index + 1)) { 2967 this.raiseError(`Should find at least ${index + 1} codefix(es), but ${actions ? actions.length : "none"} found.`); 2968 } 2969 } 2970 2971 const action = actions[index]; 2972 2973 if (typeof options.description === "string") { 2974 assert.equal(action.description, options.description); 2975 } 2976 else if (Array.isArray(options.description)) { 2977 const description = ts.formatStringFromArgs(options.description[0], options.description, 1); 2978 assert.equal(action.description, description); 2979 } 2980 else { 2981 assert.match(action.description, templateToRegExp(options.description.template)); 2982 } 2983 assert.deepEqual(action.commands, options.commands); 2984 2985 if (options.applyChanges) { 2986 for (const change of action.changes) { 2987 this.applyEdits(change.fileName, change.textChanges); 2988 } 2989 this.verifyNewContentAfterChange(options, action.changes.map(c => c.fileName)); 2990 } 2991 else { 2992 this.verifyNewContent(options, action.changes); 2993 } 2994 } 2995 2996 private verifyNewContent({ newFileContent, newRangeContent }: FourSlashInterface.NewContentOptions, changes: readonly ts.FileTextChanges[]): void { 2997 if (newRangeContent !== undefined) { 2998 assert(newFileContent === undefined); 2999 assert(changes.length === 1, "Affected 0 or more than 1 file, must use 'newFileContent' instead of 'newRangeContent'"); 3000 const change = ts.first(changes); 3001 assert(change.fileName = this.activeFile.fileName); 3002 const newText = ts.textChanges.applyChanges(this.getFileContent(this.activeFile.fileName), change.textChanges); 3003 const newRange = updateTextRangeForTextChanges(this.getOnlyRange(), change.textChanges); 3004 const actualText = newText.slice(newRange.pos, newRange.end); 3005 this.verifyTextMatches(actualText, /*includeWhitespace*/ true, newRangeContent); 3006 } 3007 else { 3008 if (newFileContent === undefined) throw ts.Debug.fail(); 3009 if (typeof newFileContent !== "object") newFileContent = { [this.activeFile.fileName]: newFileContent }; 3010 for (const change of changes) { 3011 const expectedNewContent = newFileContent[change.fileName]; 3012 if (expectedNewContent === undefined) { 3013 ts.Debug.fail(`Did not expect a change in ${change.fileName}`); 3014 } 3015 const oldText = this.tryGetFileContent(change.fileName); 3016 ts.Debug.assert(!!change.isNewFile === (oldText === undefined)); 3017 const newContent = change.isNewFile ? ts.first(change.textChanges).newText : ts.textChanges.applyChanges(oldText!, change.textChanges); 3018 this.verifyTextMatches(newContent, /*includeWhitespace*/ true, expectedNewContent); 3019 } 3020 for (const newFileName in newFileContent) { 3021 ts.Debug.assert(changes.some(c => c.fileName === newFileName), "No change in file", () => newFileName); 3022 } 3023 } 3024 } 3025 3026 private verifyNewContentAfterChange({ newFileContent, newRangeContent }: FourSlashInterface.NewContentOptions, changedFiles: readonly string[]) { 3027 const assertedChangedFiles = !newFileContent || typeof newFileContent === "string" 3028 ? [this.activeFile.fileName] 3029 : ts.getOwnKeys(newFileContent); 3030 assert.deepEqual(assertedChangedFiles, changedFiles); 3031 3032 if (newFileContent !== undefined) { 3033 assert(!newRangeContent); 3034 if (typeof newFileContent === "string") { 3035 this.verifyCurrentFileContent(newFileContent); 3036 } 3037 else { 3038 for (const fileName in newFileContent) { 3039 this.verifyFileContent(fileName, newFileContent[fileName]); 3040 } 3041 } 3042 } 3043 else { 3044 this.verifyRangeIs(newRangeContent!, /*includeWhitespace*/ true); 3045 } 3046 } 3047 3048 /** 3049 * Rerieves a codefix satisfying the parameters, or undefined if no such codefix is found. 3050 * @param fileName Path to file where error should be retrieved from. 3051 */ 3052 private getCodeFixes(fileName: string, errorCode?: number, preferences: ts.UserPreferences = ts.emptyOptions, position?: number): readonly ts.CodeFixAction[] { 3053 const diagnosticsForCodeFix = this.getDiagnostics(fileName, /*includeSuggestions*/ true).map(diagnostic => ({ 3054 start: diagnostic.start, 3055 length: diagnostic.length, 3056 code: diagnostic.code 3057 })); 3058 3059 return ts.flatMap(ts.deduplicate(diagnosticsForCodeFix, ts.equalOwnProperties), diagnostic => { 3060 if (errorCode !== undefined && errorCode !== diagnostic.code) { 3061 return; 3062 } 3063 if (position !== undefined && diagnostic.start !== undefined && diagnostic.length !== undefined) { 3064 const span = ts.createTextRangeFromSpan({ start: diagnostic.start, length: diagnostic.length }); 3065 if (!ts.textRangeContainsPositionInclusive(span, position)) { 3066 return; 3067 } 3068 } 3069 return this.languageService.getCodeFixesAtPosition(fileName, diagnostic.start!, diagnostic.start! + diagnostic.length!, [diagnostic.code], this.formatCodeSettings, preferences); 3070 }); 3071 } 3072 3073 private applyChanges(changes: readonly ts.FileTextChanges[]): void { 3074 for (const change of changes) { 3075 this.applyEdits(change.fileName, change.textChanges); 3076 } 3077 } 3078 3079 public verifyImportFixAtPosition(expectedTextArray: string[], errorCode: number | undefined, preferences: ts.UserPreferences | undefined) { 3080 const { fileName } = this.activeFile; 3081 const ranges = this.getRanges().filter(r => r.fileName === fileName); 3082 if (ranges.length > 1) { 3083 this.raiseError("Exactly one range should be specified in the testfile."); 3084 } 3085 const range = ts.firstOrUndefined(ranges); 3086 3087 if (preferences) { 3088 this.configure(preferences); 3089 } 3090 3091 const codeFixes = this.getCodeFixes(fileName, errorCode, preferences).filter(f => f.fixName === ts.codefix.importFixName); 3092 3093 if (codeFixes.length === 0) { 3094 if (expectedTextArray.length !== 0) { 3095 this.raiseError("No codefixes returned."); 3096 } 3097 return; 3098 } 3099 3100 const actualTextArray: string[] = []; 3101 const scriptInfo = this.languageServiceAdapterHost.getScriptInfo(fileName)!; 3102 const originalContent = scriptInfo.content; 3103 for (const codeFix of codeFixes) { 3104 ts.Debug.assert(codeFix.changes.length === 1); 3105 const change = ts.first(codeFix.changes); 3106 ts.Debug.assert(change.fileName === fileName); 3107 this.applyEdits(change.fileName, change.textChanges); 3108 const text = range ? this.rangeText(range) : this.getFileContent(fileName); 3109 actualTextArray.push(text); 3110 3111 // Undo changes to perform next fix 3112 const span = change.textChanges[0].span; 3113 const deletedText = originalContent.substr(span.start, change.textChanges[0].span.length); 3114 const insertedText = change.textChanges[0].newText; 3115 this.editScriptAndUpdateMarkers(fileName, span.start, span.start + insertedText.length, deletedText); 3116 } 3117 if (expectedTextArray.length !== actualTextArray.length) { 3118 this.raiseError(`Expected ${expectedTextArray.length} import fixes, got ${actualTextArray.length}:\n\n${actualTextArray.join("\n\n" + "-".repeat(20) + "\n\n")}`); 3119 } 3120 ts.zipWith(expectedTextArray, actualTextArray, (expected, actual, index) => { 3121 if (expected !== actual) { 3122 this.raiseError(`Import fix at index ${index} doesn't match.\n${showTextDiff(expected, actual)}`); 3123 } 3124 }); 3125 } 3126 3127 public verifyImportFixModuleSpecifiers(markerName: string, moduleSpecifiers: string[], preferences?: ts.UserPreferences) { 3128 const marker = this.getMarkerByName(markerName); 3129 const codeFixes = this.getCodeFixes(marker.fileName, ts.Diagnostics.Cannot_find_name_0.code, { 3130 includeCompletionsForModuleExports: true, 3131 includeCompletionsWithInsertText: true, 3132 ...preferences, 3133 }, marker.position).filter(f => f.fixName === ts.codefix.importFixName); 3134 3135 const actualModuleSpecifiers = ts.mapDefined(codeFixes, fix => { 3136 return ts.forEach(ts.flatMap(fix.changes, c => c.textChanges), c => { 3137 const match = /(?:from |require\()(['"])((?:(?!\1).)*)\1/.exec(c.newText); 3138 return match?.[2]; 3139 }); 3140 }); 3141 3142 assert.deepEqual(actualModuleSpecifiers, moduleSpecifiers); 3143 } 3144 3145 public verifyDocCommentTemplate(expected: ts.TextInsertion | undefined, options?: ts.DocCommentTemplateOptions) { 3146 const name = "verifyDocCommentTemplate"; 3147 const actual = this.languageService.getDocCommentTemplateAtPosition(this.activeFile.fileName, this.currentCaretPosition, options || { generateReturnInDocTemplate: true })!; 3148 3149 if (expected === undefined) { 3150 if (actual) { 3151 this.raiseError(`${name} failed - expected no template but got {newText: "${actual.newText}", caretOffset: ${actual.caretOffset}}`); 3152 } 3153 3154 return; 3155 } 3156 else { 3157 if (actual === undefined) { 3158 this.raiseError(`${name} failed - expected the template {newText: "${expected.newText}", caretOffset: "${expected.caretOffset}"} but got nothing instead`); 3159 } 3160 3161 if (actual.newText !== expected.newText) { 3162 this.raiseError(`${name} failed for expected insertion.\n${showTextDiff(expected.newText, actual.newText)}`); 3163 } 3164 3165 if (actual.caretOffset !== expected.caretOffset) { 3166 this.raiseError(`${name} failed - expected caretOffset: ${expected.caretOffset}\nactual caretOffset:${actual.caretOffset}`); 3167 } 3168 } 3169 } 3170 3171 public verifyBraceCompletionAtPosition(negative: boolean, openingBrace: string) { 3172 3173 const openBraceMap = new ts.Map(ts.getEntries<ts.CharacterCodes>({ 3174 "(": ts.CharacterCodes.openParen, 3175 "{": ts.CharacterCodes.openBrace, 3176 "[": ts.CharacterCodes.openBracket, 3177 "'": ts.CharacterCodes.singleQuote, 3178 '"': ts.CharacterCodes.doubleQuote, 3179 "`": ts.CharacterCodes.backtick, 3180 "<": ts.CharacterCodes.lessThan 3181 })); 3182 3183 const charCode = openBraceMap.get(openingBrace); 3184 3185 if (!charCode) { 3186 throw this.raiseError(`Invalid openingBrace '${openingBrace}' specified.`); 3187 } 3188 3189 const position = this.currentCaretPosition; 3190 3191 const validBraceCompletion = this.languageService.isValidBraceCompletionAtPosition(this.activeFile.fileName, position, charCode); 3192 3193 if (!negative && !validBraceCompletion) { 3194 this.raiseError(`${position} is not a valid brace completion position for ${openingBrace}`); 3195 } 3196 3197 if (negative && validBraceCompletion) { 3198 this.raiseError(`${position} is a valid brace completion position for ${openingBrace}`); 3199 } 3200 } 3201 3202 public verifyJsxClosingTag(map: { [markerName: string]: ts.JsxClosingTagInfo | undefined }): void { 3203 for (const markerName in map) { 3204 this.goToMarker(markerName); 3205 const actual = this.languageService.getJsxClosingTagAtPosition(this.activeFile.fileName, this.currentCaretPosition); 3206 assert.deepEqual(actual, map[markerName], markerName); 3207 } 3208 } 3209 3210 public verifyMatchingBracePosition(bracePosition: number, expectedMatchPosition: number) { 3211 const actual = this.languageService.getBraceMatchingAtPosition(this.activeFile.fileName, bracePosition); 3212 3213 if (actual.length !== 2) { 3214 this.raiseError(`verifyMatchingBracePosition failed - expected result to contain 2 spans, but it had ${actual.length}`); 3215 } 3216 3217 let actualMatchPosition = -1; 3218 if (bracePosition === actual[0].start) { 3219 actualMatchPosition = actual[1].start; 3220 } 3221 else if (bracePosition === actual[1].start) { 3222 actualMatchPosition = actual[0].start; 3223 } 3224 else { 3225 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])})`); 3226 } 3227 3228 if (actualMatchPosition !== expectedMatchPosition) { 3229 this.raiseError(`verifyMatchingBracePosition failed - expected: ${actualMatchPosition}, actual: ${expectedMatchPosition}`); 3230 } 3231 } 3232 3233 public verifyNoMatchingBracePosition(bracePosition: number) { 3234 const actual = this.languageService.getBraceMatchingAtPosition(this.activeFile.fileName, bracePosition); 3235 3236 if (actual.length !== 0) { 3237 this.raiseError("verifyNoMatchingBracePosition failed - expected: 0 spans, actual: " + actual.length); 3238 } 3239 } 3240 3241 public verifySpanOfEnclosingComment(negative: boolean, onlyMultiLineDiverges?: boolean) { 3242 const expected = !negative; 3243 const position = this.currentCaretPosition; 3244 const fileName = this.activeFile.fileName; 3245 const actual = !!this.languageService.getSpanOfEnclosingComment(fileName, position, /*onlyMultiLine*/ false); 3246 const actualOnlyMultiLine = !!this.languageService.getSpanOfEnclosingComment(fileName, position, /*onlyMultiLine*/ true); 3247 if (expected !== actual || onlyMultiLineDiverges === (actual === actualOnlyMultiLine)) { 3248 this.raiseError(`verifySpanOfEnclosingComment failed: 3249 position: '${position}' 3250 fileName: '${fileName}' 3251 onlyMultiLineDiverges: '${onlyMultiLineDiverges}' 3252 actual: '${actual}' 3253 actualOnlyMultiLine: '${actualOnlyMultiLine}' 3254 expected: '${expected}'.`); 3255 } 3256 } 3257 3258 public verifyNavigateTo(options: readonly FourSlashInterface.VerifyNavigateToOptions[]): void { 3259 for (const { pattern, expected, fileName } of options) { 3260 const items = this.languageService.getNavigateToItems(pattern, /*maxResultCount*/ undefined, fileName); 3261 this.assertObjectsEqual(items, expected.map((e): ts.NavigateToItem => ({ 3262 name: e.name, 3263 kind: e.kind, 3264 kindModifiers: e.kindModifiers || "", 3265 matchKind: e.matchKind || "exact", 3266 isCaseSensitive: e.isCaseSensitive === undefined ? true : e.isCaseSensitive, 3267 fileName: e.range.fileName, 3268 textSpan: ts.createTextSpanFromRange(e.range), 3269 containerName: e.containerName || "", 3270 containerKind: e.containerKind || ts.ScriptElementKind.unknown, 3271 }))); 3272 } 3273 } 3274 3275 public verifyNavigationBar(json: any, options: { checkSpans?: boolean } | undefined) { 3276 this.verifyNavigationTreeOrBar(json, this.languageService.getNavigationBarItems(this.activeFile.fileName), "Bar", options); 3277 } 3278 3279 public verifyNavigationTree(json: any, options: { checkSpans?: boolean } | undefined) { 3280 this.verifyNavigationTreeOrBar(json, this.languageService.getNavigationTree(this.activeFile.fileName), "Tree", options); 3281 } 3282 3283 private verifyNavigationTreeOrBar(json: any, tree: any, name: "Tree" | "Bar", options: { checkSpans?: boolean } | undefined) { 3284 if (JSON.stringify(tree, replacer) !== JSON.stringify(json)) { 3285 this.raiseError(`verifyNavigation${name} failed - \n${showTextDiff(stringify(json), stringify(tree, replacer))}`); 3286 } 3287 3288 function replacer(key: string, value: any) { 3289 switch (key) { 3290 case "spans": 3291 case "nameSpan": 3292 return options && options.checkSpans ? value : undefined; 3293 case "start": 3294 case "length": 3295 // Never omit the values in a span, even if they are 0. 3296 return value; 3297 case "childItems": 3298 return !value || value.length === 0 ? undefined : value; 3299 default: 3300 // Omit falsy values, those are presumed to be the default. 3301 return value || undefined; 3302 } 3303 } 3304 } 3305 3306 public printNavigationItems(searchValue: string) { 3307 const items = this.languageService.getNavigateToItems(searchValue); 3308 Harness.IO.log(`NavigationItems list (${items.length} items)`); 3309 for (const item of items) { 3310 Harness.IO.log(`name: ${item.name}, kind: ${item.kind}, parentName: ${item.containerName}, fileName: ${item.fileName}`); 3311 } 3312 } 3313 3314 public printNavigationBar() { 3315 const items = this.languageService.getNavigationBarItems(this.activeFile.fileName); 3316 Harness.IO.log(`Navigation bar (${items.length} items)`); 3317 for (const item of items) { 3318 Harness.IO.log(`${ts.repeatString(" ", item.indent)}name: ${item.text}, kind: ${item.kind}, childItems: ${item.childItems.map(child => child.text)}`); 3319 } 3320 } 3321 3322 private getOccurrencesAtCurrentPosition() { 3323 return this.languageService.getOccurrencesAtPosition(this.activeFile.fileName, this.currentCaretPosition); 3324 } 3325 3326 public verifyOccurrencesAtPositionListContains(fileName: string, start: number, end: number, isWriteAccess?: boolean) { 3327 const occurrences = this.getOccurrencesAtCurrentPosition(); 3328 3329 if (!occurrences || occurrences.length === 0) { 3330 return this.raiseError("verifyOccurrencesAtPositionListContains failed - found 0 references, expected at least one."); 3331 } 3332 3333 for (const occurrence of occurrences) { 3334 if (occurrence && occurrence.fileName === fileName && occurrence.textSpan.start === start && ts.textSpanEnd(occurrence.textSpan) === end) { 3335 if (typeof isWriteAccess !== "undefined" && occurrence.isWriteAccess !== isWriteAccess) { 3336 this.raiseError(`verifyOccurrencesAtPositionListContains failed - item isWriteAccess value does not match, actual: ${occurrence.isWriteAccess}, expected: ${isWriteAccess}.`); 3337 } 3338 return; 3339 } 3340 } 3341 3342 const missingItem = { fileName, start, end, isWriteAccess }; 3343 this.raiseError(`verifyOccurrencesAtPositionListContains failed - could not find the item: ${stringify(missingItem)} in the returned list: (${stringify(occurrences)})`); 3344 } 3345 3346 public verifyOccurrencesAtPositionListCount(expectedCount: number) { 3347 const occurrences = this.getOccurrencesAtCurrentPosition(); 3348 const actualCount = occurrences ? occurrences.length : 0; 3349 if (expectedCount !== actualCount) { 3350 this.raiseError(`verifyOccurrencesAtPositionListCount failed - actual: ${actualCount}, expected:${expectedCount}`); 3351 } 3352 } 3353 3354 private getDocumentHighlightsAtCurrentPosition(fileNamesToSearch: readonly string[]) { 3355 const filesToSearch = fileNamesToSearch.map(name => ts.combinePaths(this.basePath, name)); 3356 return this.languageService.getDocumentHighlights(this.activeFile.fileName, this.currentCaretPosition, filesToSearch); 3357 } 3358 3359 public verifyRangesAreOccurrences(isWriteAccess?: boolean, ranges?: Range[]) { 3360 ranges = ranges || this.getRanges(); 3361 assert(ranges.length); 3362 for (const r of ranges) { 3363 this.goToRangeStart(r); 3364 this.verifyOccurrencesAtPositionListCount(ranges.length); 3365 for (const range of ranges) { 3366 this.verifyOccurrencesAtPositionListContains(range.fileName, range.pos, range.end, isWriteAccess); 3367 } 3368 } 3369 } 3370 3371 public verifyRangesWithSameTextAreRenameLocations(...texts: string[]) { 3372 if (texts.length) { 3373 texts.forEach(text => this.verifyRangesAreRenameLocations(this.rangesByText().get(text))); 3374 } 3375 else { 3376 this.rangesByText().forEach(ranges => this.verifyRangesAreRenameLocations(ranges)); 3377 } 3378 } 3379 3380 public verifyRangesWithSameTextAreDocumentHighlights() { 3381 this.rangesByText().forEach(ranges => this.verifyRangesAreDocumentHighlights(ranges, /*options*/ undefined)); 3382 } 3383 3384 public verifyDocumentHighlightsOf(startRange: Range, ranges: Range[], options: FourSlashInterface.VerifyDocumentHighlightsOptions | undefined) { 3385 const fileNames = options && options.filesToSearch || unique(ranges, range => range.fileName); 3386 this.goToRangeStart(startRange); 3387 this.verifyDocumentHighlights(ranges, fileNames); 3388 } 3389 3390 public verifyRangesAreDocumentHighlights(ranges: Range[] | undefined, options: FourSlashInterface.VerifyDocumentHighlightsOptions | undefined) { 3391 ranges = ranges || this.getRanges(); 3392 assert(ranges.length); 3393 const fileNames = options && options.filesToSearch || unique(ranges, range => range.fileName); 3394 for (const range of ranges) { 3395 this.goToRangeStart(range); 3396 this.verifyDocumentHighlights(ranges, fileNames); 3397 } 3398 } 3399 3400 public verifyNoDocumentHighlights(startRange: Range) { 3401 this.goToRangeStart(startRange); 3402 const documentHighlights = this.getDocumentHighlightsAtCurrentPosition([this.activeFile.fileName]); 3403 const numHighlights = ts.length(documentHighlights); 3404 if (numHighlights > 0) { 3405 this.raiseError(`verifyNoDocumentHighlights failed - unexpectedly got ${numHighlights} highlights`); 3406 } 3407 } 3408 3409 private verifyDocumentHighlights(expectedRanges: Range[], fileNames: readonly string[] = [this.activeFile.fileName]) { 3410 fileNames = ts.map(fileNames, ts.normalizePath); 3411 const documentHighlights = this.getDocumentHighlightsAtCurrentPosition(fileNames) || []; 3412 3413 for (const dh of documentHighlights) { 3414 if (fileNames.indexOf(dh.fileName) === -1) { 3415 this.raiseError(`verifyDocumentHighlights failed - got highlights in unexpected file name ${dh.fileName}`); 3416 } 3417 } 3418 3419 for (const fileName of fileNames) { 3420 const expectedRangesInFile = expectedRanges.filter(r => ts.normalizePath(r.fileName) === fileName); 3421 const highlights = ts.find(documentHighlights, dh => dh.fileName === fileName); 3422 const spansInFile = highlights ? highlights.highlightSpans.sort((s1, s2) => s1.textSpan.start - s2.textSpan.start) : []; 3423 3424 if (expectedRangesInFile.length !== spansInFile.length) { 3425 this.raiseError(`verifyDocumentHighlights failed - In ${fileName}, expected ${expectedRangesInFile.length} highlights, got ${spansInFile.length}`); 3426 } 3427 3428 ts.zipWith(expectedRangesInFile, spansInFile, (expectedRange, span) => { 3429 if (span.textSpan.start !== expectedRange.pos || ts.textSpanEnd(span.textSpan) !== expectedRange.end) { 3430 this.raiseError(`verifyDocumentHighlights failed - span does not match, actual: ${stringify(span.textSpan)}, expected: ${expectedRange.pos}--${expectedRange.end}`); 3431 } 3432 }); 3433 } 3434 } 3435 3436 public verifyCodeFixAvailable(negative: boolean, expected: FourSlashInterface.VerifyCodeFixAvailableOptions[] | string | undefined): void { 3437 const codeFixes = this.getCodeFixes(this.activeFile.fileName); 3438 if (negative) { 3439 if (typeof expected === "undefined") { 3440 this.assertObjectsEqual(codeFixes, ts.emptyArray); 3441 } 3442 else if (typeof expected === "string") { 3443 if (codeFixes.some(fix => fix.fixName === expected)) { 3444 this.raiseError(`Expected not to find a fix with the name '${expected}', but one exists.`); 3445 } 3446 } 3447 else { 3448 assert(typeof expected === "undefined" || typeof expected === "string", "With a negated assertion, 'expected' must be undefined or a string value of a codefix name."); 3449 } 3450 } 3451 else if (typeof expected === "string") { 3452 this.assertObjectsEqual(codeFixes.map(fix => fix.fixName), [expected]); 3453 } 3454 else { 3455 const actuals = codeFixes.map((fix): FourSlashInterface.VerifyCodeFixAvailableOptions => ({ description: fix.description, commands: fix.commands })); 3456 this.assertObjectsEqual(actuals, negative ? ts.emptyArray : expected); 3457 } 3458 } 3459 3460 public verifyCodeFixAllAvailable(negative: boolean, fixName: string) { 3461 const availableFixes = this.getCodeFixes(this.activeFile.fileName); 3462 const hasFix = availableFixes.some(fix => fix.fixName === fixName && fix.fixId); 3463 if (negative && hasFix) { 3464 this.raiseError(`Expected not to find a fix with the name '${fixName}', but one exists.`); 3465 } 3466 else if (!negative && !hasFix) { 3467 if (availableFixes.some(fix => fix.fixName === fixName)) { 3468 this.raiseError(`Found a fix with the name '${fixName}', but fix-all is not available.`); 3469 } 3470 3471 this.raiseError( 3472 `Expected to find a fix with the name '${fixName}', but none exists.` + 3473 availableFixes.length 3474 ? ` Available fixes: ${availableFixes.map(fix => `${fix.fixName} (${fix.fixId ? "with" : "without"} fix-all)`).join(", ")}` 3475 : "" 3476 ); 3477 } 3478 } 3479 3480 public verifyApplicableRefactorAvailableAtMarker(negative: boolean, markerName: string) { 3481 const isAvailable = this.getApplicableRefactors(this.getMarkerByName(markerName)).length > 0; 3482 if (negative && isAvailable) { 3483 this.raiseError(`verifyApplicableRefactorAvailableAtMarker failed - expected no refactor at marker ${markerName} but found some.`); 3484 } 3485 if (!negative && !isAvailable) { 3486 this.raiseError(`verifyApplicableRefactorAvailableAtMarker failed - expected a refactor at marker ${markerName} but found none.`); 3487 } 3488 } 3489 3490 private getSelection(): ts.TextRange { 3491 return { 3492 pos: this.currentCaretPosition, 3493 end: this.selectionEnd === -1 ? this.currentCaretPosition : this.selectionEnd 3494 }; 3495 } 3496 3497 public verifyRefactorAvailable(negative: boolean, triggerReason: ts.RefactorTriggerReason, name: string, actionName?: string, actionDescription?: string) { 3498 let refactors = this.getApplicableRefactorsAtSelection(triggerReason); 3499 refactors = refactors.filter(r => r.name === name); 3500 3501 if (actionName !== undefined) { 3502 refactors.forEach(r => r.actions = r.actions.filter(a => a.name === actionName)); 3503 } 3504 3505 if (actionDescription !== undefined) { 3506 refactors.forEach(r => r.actions = r.actions.filter(a => a.description === actionDescription)); 3507 } 3508 3509 refactors = refactors.filter(r => r.actions.length > 0); 3510 3511 const isAvailable = refactors.length > 0; 3512 3513 if (negative) { 3514 if (isAvailable) { 3515 this.raiseError(`verifyApplicableRefactorAvailableForRange failed - expected no refactor but found: ${refactors.map(r => r.name).join(", ")}`); 3516 } 3517 } 3518 else { 3519 if (!isAvailable) { 3520 this.raiseError(`verifyApplicableRefactorAvailableForRange failed - expected a refactor but found none.`); 3521 } 3522 if (refactors.length > 1) { 3523 this.raiseError(`${refactors.length} available refactors both have name ${name} and action ${actionName}`); 3524 } 3525 } 3526 } 3527 3528 public verifyRefactorKindsAvailable(kind: string, expected: string[], preferences = ts.emptyOptions) { 3529 const refactors = this.getApplicableRefactorsAtSelection("invoked", kind, preferences); 3530 const availableKinds = ts.flatMap(refactors, refactor => refactor.actions).map(action => action.kind); 3531 assert.deepEqual(availableKinds.sort(), expected.sort(), `Expected kinds to be equal`); 3532 } 3533 3534 public verifyRefactorsAvailable(names: readonly string[]): void { 3535 assert.deepEqual(unique(this.getApplicableRefactorsAtSelection(), r => r.name), names); 3536 } 3537 3538 public verifyApplicableRefactorAvailableForRange(negative: boolean) { 3539 const ranges = this.getRanges(); 3540 if (!(ranges && ranges.length === 1)) { 3541 throw new Error("Exactly one refactor range is allowed per test."); 3542 } 3543 3544 const isAvailable = this.getApplicableRefactors(ts.first(ranges)).length > 0; 3545 if (negative && isAvailable) { 3546 this.raiseError(`verifyApplicableRefactorAvailableForRange failed - expected no refactor but found some.`); 3547 } 3548 if (!negative && !isAvailable) { 3549 this.raiseError(`verifyApplicableRefactorAvailableForRange failed - expected a refactor but found none.`); 3550 } 3551 } 3552 3553 public applyRefactor({ refactorName, actionName, actionDescription, newContent: newContentWithRenameMarker, triggerReason }: FourSlashInterface.ApplyRefactorOptions) { 3554 const range = this.getSelection(); 3555 const refactors = this.getApplicableRefactorsAtSelection(triggerReason); 3556 const refactorsWithName = refactors.filter(r => r.name === refactorName); 3557 if (refactorsWithName.length === 0) { 3558 this.raiseError(`The expected refactor: ${refactorName} is not available at the marker location.\nAvailable refactors: ${refactors.map(r => r.name)}`); 3559 } 3560 3561 const action = ts.firstDefined(refactorsWithName, refactor => refactor.actions.find(a => a.name === actionName)); 3562 if (!action) { 3563 throw this.raiseError(`The expected action: ${actionName} is not included in: ${ts.flatMap(refactorsWithName, r => r.actions.map(a => a.name))}`); 3564 } 3565 if (action.description !== actionDescription) { 3566 this.raiseError(`Expected action description to be ${JSON.stringify(actionDescription)}, got: ${JSON.stringify(action.description)}`); 3567 } 3568 3569 const editInfo = this.languageService.getEditsForRefactor(this.activeFile.fileName, this.formatCodeSettings, range, refactorName, actionName, ts.emptyOptions)!; 3570 for (const edit of editInfo.edits) { 3571 this.applyEdits(edit.fileName, edit.textChanges); 3572 } 3573 3574 let renameFilename: string | undefined; 3575 let renamePosition: number | undefined; 3576 3577 const newFileContents = typeof newContentWithRenameMarker === "string" ? { [this.activeFile.fileName]: newContentWithRenameMarker } : newContentWithRenameMarker; 3578 for (const fileName in newFileContents) { 3579 const { renamePosition: rp, newContent } = TestState.parseNewContent(newFileContents[fileName]); 3580 if (renamePosition === undefined) { 3581 renameFilename = fileName; 3582 renamePosition = rp; 3583 } 3584 else { 3585 ts.Debug.assert(rp === undefined); 3586 } 3587 this.verifyFileContent(fileName, newContent); 3588 3589 } 3590 3591 if (renamePosition === undefined) { 3592 if (editInfo.renameLocation !== undefined) { 3593 this.raiseError(`Did not expect a rename location, got ${editInfo.renameLocation}`); 3594 } 3595 } 3596 else { 3597 this.assertObjectsEqual(editInfo.renameFilename, renameFilename); 3598 if (renamePosition !== editInfo.renameLocation) { 3599 this.raiseError(`Expected rename position of ${renamePosition}, but got ${editInfo.renameLocation}`); 3600 } 3601 } 3602 } 3603 3604 private static parseNewContent(newContentWithRenameMarker: string): { readonly renamePosition: number | undefined, readonly newContent: string } { 3605 const renamePosition = newContentWithRenameMarker.indexOf("/*RENAME*/"); 3606 if (renamePosition === -1) { 3607 return { renamePosition: undefined, newContent: newContentWithRenameMarker }; 3608 } 3609 else { 3610 const newContent = newContentWithRenameMarker.slice(0, renamePosition) + newContentWithRenameMarker.slice(renamePosition + "/*RENAME*/".length); 3611 return { renamePosition, newContent }; 3612 } 3613 } 3614 3615 public noMoveToNewFile() { 3616 const ranges = this.getRanges(); 3617 assert(ranges.length); 3618 for (const range of ranges) { 3619 for (const refactor of this.getApplicableRefactors(range, { allowTextChangesInNewFiles: true })) { 3620 if (refactor.name === "Move to a new file") { 3621 ts.Debug.fail("Did not expect to get 'move to a new file' refactor"); 3622 } 3623 } 3624 } 3625 } 3626 3627 public moveToNewFile(options: FourSlashInterface.MoveToNewFileOptions): void { 3628 assert(this.getRanges().length === 1, "Must have exactly one fourslash range (source enclosed between '[|' and '|]' delimiters) in the source file"); 3629 const range = this.getRanges()[0]; 3630 const refactor = ts.find(this.getApplicableRefactors(range, { allowTextChangesInNewFiles: true }), r => r.name === "Move to a new file")!; 3631 assert(refactor.actions.length === 1); 3632 const action = ts.first(refactor.actions); 3633 assert(action.name === "Move to a new file" && action.description === "Move to a new file"); 3634 3635 const editInfo = this.languageService.getEditsForRefactor(range.fileName, this.formatCodeSettings, range, refactor.name, action.name, options.preferences || ts.emptyOptions)!; 3636 this.verifyNewContent({ newFileContent: options.newFileContents }, editInfo.edits); 3637 } 3638 3639 private testNewFileContents(edits: readonly ts.FileTextChanges[], newFileContents: { [fileName: string]: string }, description: string): void { 3640 for (const { fileName, textChanges } of edits) { 3641 const newContent = newFileContents[fileName]; 3642 if (newContent === undefined) { 3643 this.raiseError(`${description} - There was an edit in ${fileName} but new content was not specified.`); 3644 } 3645 3646 const fileContent = this.tryGetFileContent(fileName); 3647 if (fileContent !== undefined) { 3648 const actualNewContent = ts.textChanges.applyChanges(fileContent, textChanges); 3649 assert.equal(actualNewContent, newContent, `new content for ${fileName}`); 3650 } 3651 else { 3652 // Creates a new file. 3653 assert(textChanges.length === 1); 3654 const change = ts.first(textChanges); 3655 assert.deepEqual(change.span, ts.createTextSpan(0, 0)); 3656 assert.equal(change.newText, newContent, `${description} - Content for ${fileName}`); 3657 } 3658 } 3659 3660 for (const fileName in newFileContents) { 3661 if (!edits.some(e => e.fileName === fileName)) { 3662 ts.Debug.fail(`${description} - Asserted new contents of ${fileName} but there were no edits`); 3663 } 3664 } 3665 } 3666 3667 public verifyFileAfterApplyingRefactorAtMarker( 3668 markerName: string, 3669 expectedContent: string, 3670 refactorNameToApply: string, 3671 actionName: string, 3672 formattingOptions?: ts.FormatCodeSettings) { 3673 3674 formattingOptions = formattingOptions || this.formatCodeSettings; 3675 const marker = this.getMarkerByName(markerName); 3676 3677 const applicableRefactors = this.languageService.getApplicableRefactors(this.activeFile.fileName, marker.position, ts.emptyOptions); 3678 const applicableRefactorToApply = ts.find(applicableRefactors, refactor => refactor.name === refactorNameToApply); 3679 3680 if (!applicableRefactorToApply) { 3681 this.raiseError(`The expected refactor: ${refactorNameToApply} is not available at the marker location.`); 3682 } 3683 3684 const editInfo = this.languageService.getEditsForRefactor(marker.fileName, formattingOptions, marker.position, refactorNameToApply, actionName, ts.emptyOptions)!; 3685 3686 for (const edit of editInfo.edits) { 3687 this.applyEdits(edit.fileName, edit.textChanges); 3688 } 3689 const actualContent = this.getFileContent(marker.fileName); 3690 3691 if (actualContent !== expectedContent) { 3692 this.raiseError(`verifyFileAfterApplyingRefactors failed:\n${showTextDiff(expectedContent, actualContent)}`); 3693 } 3694 } 3695 3696 public printAvailableCodeFixes() { 3697 const codeFixes = this.getCodeFixes(this.activeFile.fileName); 3698 Harness.IO.log(stringify(codeFixes)); 3699 } 3700 3701 private formatCallHierarchyItemSpan(file: FourSlashFile, span: ts.TextSpan, prefix: string, trailingPrefix = prefix) { 3702 const startLc = this.languageServiceAdapterHost.positionToLineAndCharacter(file.fileName, span.start); 3703 const endLc = this.languageServiceAdapterHost.positionToLineAndCharacter(file.fileName, ts.textSpanEnd(span)); 3704 const lines = this.spanLines(file, span, { fullLines: true, lineNumbers: true, selection: true }); 3705 let text = ""; 3706 text += `${prefix}╭ ${file.fileName}:${startLc.line + 1}:${startLc.character + 1}-${endLc.line + 1}:${endLc.character + 1}\n`; 3707 for (const line of lines) { 3708 text += `${prefix}│ ${line.trimRight()}\n`; 3709 } 3710 text += `${trailingPrefix}╰\n`; 3711 return text; 3712 } 3713 3714 private formatCallHierarchyItemSpans(file: FourSlashFile, spans: ts.TextSpan[], prefix: string, trailingPrefix = prefix) { 3715 let text = ""; 3716 for (let i = 0; i < spans.length; i++) { 3717 text += this.formatCallHierarchyItemSpan(file, spans[i], prefix, i < spans.length - 1 ? prefix : trailingPrefix); 3718 } 3719 return text; 3720 } 3721 3722 private formatCallHierarchyItem(file: FourSlashFile, callHierarchyItem: ts.CallHierarchyItem, direction: CallHierarchyItemDirection, seen: ts.ESMap<string, boolean>, prefix: string, trailingPrefix: string = prefix) { 3723 const key = `${callHierarchyItem.file}|${JSON.stringify(callHierarchyItem.span)}|${direction}`; 3724 const alreadySeen = seen.has(key); 3725 seen.set(key, true); 3726 3727 const incomingCalls = 3728 direction === CallHierarchyItemDirection.Outgoing ? { result: "skip" } as const : 3729 alreadySeen ? { result: "seen" } as const : 3730 { result: "show", values: this.languageService.provideCallHierarchyIncomingCalls(callHierarchyItem.file, callHierarchyItem.selectionSpan.start) } as const; 3731 3732 const outgoingCalls = 3733 direction === CallHierarchyItemDirection.Incoming ? { result: "skip" } as const : 3734 alreadySeen ? { result: "seen" } as const : 3735 { result: "show", values: this.languageService.provideCallHierarchyOutgoingCalls(callHierarchyItem.file, callHierarchyItem.selectionSpan.start) } as const; 3736 3737 let text = ""; 3738 text += `${prefix}╭ name: ${callHierarchyItem.name}\n`; 3739 text += `${prefix}├ kind: ${callHierarchyItem.kind}\n`; 3740 if (callHierarchyItem.containerName) { 3741 text += `${prefix}├ containerName: ${callHierarchyItem.containerName}\n`; 3742 } 3743 text += `${prefix}├ file: ${callHierarchyItem.file}\n`; 3744 text += `${prefix}├ span:\n`; 3745 text += this.formatCallHierarchyItemSpan(file, callHierarchyItem.span, `${prefix}│ `); 3746 text += `${prefix}├ selectionSpan:\n`; 3747 text += this.formatCallHierarchyItemSpan(file, callHierarchyItem.selectionSpan, `${prefix}│ `, 3748 incomingCalls.result !== "skip" || outgoingCalls.result !== "skip" ? `${prefix}│ ` : 3749 `${trailingPrefix}╰ `); 3750 3751 if (incomingCalls.result === "seen") { 3752 if (outgoingCalls.result === "skip") { 3753 text += `${trailingPrefix}╰ incoming: ...\n`; 3754 } 3755 else { 3756 text += `${prefix}├ incoming: ...\n`; 3757 } 3758 } 3759 else if (incomingCalls.result === "show") { 3760 if (!ts.some(incomingCalls.values)) { 3761 if (outgoingCalls.result === "skip") { 3762 text += `${trailingPrefix}╰ incoming: none\n`; 3763 } 3764 else { 3765 text += `${prefix}├ incoming: none\n`; 3766 } 3767 } 3768 else { 3769 text += `${prefix}├ incoming:\n`; 3770 for (let i = 0; i < incomingCalls.values.length; i++) { 3771 const incomingCall = incomingCalls.values[i]; 3772 const file = this.findFile(incomingCall.from.file); 3773 text += `${prefix}│ ╭ from:\n`; 3774 text += this.formatCallHierarchyItem(file, incomingCall.from, CallHierarchyItemDirection.Incoming, seen, `${prefix}│ │ `); 3775 text += `${prefix}│ ├ fromSpans:\n`; 3776 text += this.formatCallHierarchyItemSpans(file, incomingCall.fromSpans, `${prefix}│ │ `, 3777 i < incomingCalls.values.length - 1 ? `${prefix}│ ╰ ` : 3778 outgoingCalls.result !== "skip" ? `${prefix}│ ╰ ` : 3779 `${trailingPrefix}╰ ╰ `); 3780 } 3781 } 3782 } 3783 3784 if (outgoingCalls.result === "seen") { 3785 text += `${trailingPrefix}╰ outgoing: ...\n`; 3786 } 3787 else if (outgoingCalls.result === "show") { 3788 if (!ts.some(outgoingCalls.values)) { 3789 text += `${trailingPrefix}╰ outgoing: none\n`; 3790 } 3791 else { 3792 text += `${prefix}├ outgoing:\n`; 3793 for (let i = 0; i < outgoingCalls.values.length; i++) { 3794 const outgoingCall = outgoingCalls.values[i]; 3795 text += `${prefix}│ ╭ to:\n`; 3796 text += this.formatCallHierarchyItem(this.findFile(outgoingCall.to.file), outgoingCall.to, CallHierarchyItemDirection.Outgoing, seen, `${prefix}│ │ `); 3797 text += `${prefix}│ ├ fromSpans:\n`; 3798 text += this.formatCallHierarchyItemSpans(file, outgoingCall.fromSpans, `${prefix}│ │ `, 3799 i < outgoingCalls.values.length - 1 ? `${prefix}│ ╰ ` : 3800 `${trailingPrefix}╰ ╰ `); 3801 } 3802 } 3803 } 3804 return text; 3805 } 3806 3807 private formatCallHierarchy(callHierarchyItem: ts.CallHierarchyItem | undefined) { 3808 let text = ""; 3809 if (callHierarchyItem) { 3810 const file = this.findFile(callHierarchyItem.file); 3811 text += this.formatCallHierarchyItem(file, callHierarchyItem, CallHierarchyItemDirection.Root, new ts.Map(), ""); 3812 } 3813 return text; 3814 } 3815 3816 public baselineCallHierarchy() { 3817 const baselineFile = this.getBaselineFileNameForContainingTestFile(".callHierarchy.txt"); 3818 const callHierarchyItem = this.languageService.prepareCallHierarchy(this.activeFile.fileName, this.currentCaretPosition); 3819 const text = callHierarchyItem ? ts.mapOneOrMany(callHierarchyItem, item => this.formatCallHierarchy(item), result => result.join("")) : "none"; 3820 Harness.Baseline.runBaseline(baselineFile, text); 3821 } 3822 3823 private assertTextSpanEqualsRange(span: ts.TextSpan, range: Range, message?: string) { 3824 if (!textSpanEqualsRange(span, range)) { 3825 this.raiseError(`${prefixMessage(message)}Expected to find TextSpan ${JSON.stringify({ start: range.pos, length: range.end - range.pos })} but got ${JSON.stringify(span)} instead.`); 3826 } 3827 } 3828 3829 private getLineContent(index: number) { 3830 const text = this.getFileContent(this.activeFile.fileName); 3831 const pos = this.languageServiceAdapterHost.lineAndCharacterToPosition(this.activeFile.fileName, { line: index, character: 0 }); 3832 let startPos = pos, endPos = pos; 3833 3834 while (startPos > 0) { 3835 const ch = text.charCodeAt(startPos - 1); 3836 if (ch === ts.CharacterCodes.carriageReturn || ch === ts.CharacterCodes.lineFeed) { 3837 break; 3838 } 3839 3840 startPos--; 3841 } 3842 3843 while (endPos < text.length) { 3844 const ch = text.charCodeAt(endPos); 3845 3846 if (ch === ts.CharacterCodes.carriageReturn || ch === ts.CharacterCodes.lineFeed) { 3847 break; 3848 } 3849 3850 endPos++; 3851 } 3852 3853 return text.substring(startPos, endPos); 3854 } 3855 3856 // Get the text of the entire line the caret is currently at 3857 private getCurrentLineContent() { 3858 return this.getLineContent(this.languageServiceAdapterHost.positionToLineAndCharacter( 3859 this.activeFile.fileName, 3860 this.currentCaretPosition, 3861 ).line); 3862 } 3863 3864 private findFile(indexOrName: string | number): FourSlashFile { 3865 if (typeof indexOrName === "number") { 3866 const index = indexOrName; 3867 if (index >= this.testData.files.length) { 3868 throw new Error(`File index (${index}) in openFile was out of range. There are only ${this.testData.files.length} files in this test.`); 3869 } 3870 else { 3871 return this.testData.files[index]; 3872 } 3873 } 3874 else if (ts.isString(indexOrName)) { 3875 const { file, availableNames } = this.tryFindFileWorker(indexOrName); 3876 if (!file) { 3877 throw new Error(`No test file named "${indexOrName}" exists. Available file names are: ${availableNames.join(", ")}`); 3878 } 3879 return file; 3880 } 3881 else { 3882 return ts.Debug.assertNever(indexOrName); 3883 } 3884 } 3885 3886 private tryFindFileWorker(name: string): { readonly file: FourSlashFile | undefined; readonly availableNames: readonly string[]; } { 3887 name = ts.normalizePath(name); 3888 // names are stored in the compiler with this relative path, this allows people to use goTo.file on just the fileName 3889 name = name.indexOf("/") === -1 ? (this.basePath + "/" + name) : name; 3890 3891 const availableNames: string[] = []; 3892 const file = ts.forEach(this.testData.files, file => { 3893 const fn = ts.normalizePath(file.fileName); 3894 if (fn) { 3895 if (fn === name) { 3896 return file; 3897 } 3898 availableNames.push(fn); 3899 } 3900 }); 3901 return { file, availableNames }; 3902 } 3903 3904 private hasFile(name: string): boolean { 3905 return this.tryFindFileWorker(name).file !== undefined; 3906 } 3907 3908 private getLineColStringAtPosition(position: number, file: FourSlashFile = this.activeFile) { 3909 const pos = this.languageServiceAdapterHost.positionToLineAndCharacter(file.fileName, position); 3910 return `line ${(pos.line + 1)}, col ${pos.character}`; 3911 } 3912 3913 public getMarkerByName(markerName: string) { 3914 const markerPos = this.testData.markerPositions.get(markerName); 3915 if (markerPos === undefined) { 3916 throw new Error(`Unknown marker "${markerName}" Available markers: ${this.getMarkerNames().map(m => "\"" + m + "\"").join(", ")}`); 3917 } 3918 else { 3919 return markerPos; 3920 } 3921 } 3922 3923 public setCancelled(numberOfCalls: number): void { 3924 this.cancellationToken.setCancelled(numberOfCalls); 3925 } 3926 3927 public resetCancelled(): void { 3928 this.cancellationToken.resetCancelled(); 3929 } 3930 3931 public getEditsForFileRename({ oldPath, newPath, newFileContents, preferences }: FourSlashInterface.GetEditsForFileRenameOptions): void { 3932 const test = (fileContents: { readonly [fileName: string]: string }, description: string): void => { 3933 const changes = this.languageService.getEditsForFileRename(oldPath, newPath, this.formatCodeSettings, preferences); 3934 this.testNewFileContents(changes, fileContents, description); 3935 }; 3936 3937 ts.Debug.assert(!this.hasFile(newPath), "initially, newPath should not exist"); 3938 3939 test(newFileContents, "with file not yet moved"); 3940 3941 this.languageServiceAdapterHost.renameFileOrDirectory(oldPath, newPath); 3942 this.languageService.cleanupSemanticCache(); 3943 const pathUpdater = ts.getPathUpdater(oldPath, newPath, ts.createGetCanonicalFileName(/*useCaseSensitiveFileNames*/ false), /*sourceMapper*/ undefined); 3944 test(renameKeys(newFileContents, key => pathUpdater(key) || key), "with file moved"); 3945 } 3946 3947 private getApplicableRefactorsAtSelection(triggerReason: ts.RefactorTriggerReason = "implicit", kind?: string, preferences = ts.emptyOptions) { 3948 return this.getApplicableRefactorsWorker(this.getSelection(), this.activeFile.fileName, preferences, triggerReason, kind); 3949 } 3950 private getApplicableRefactors(rangeOrMarker: Range | Marker, preferences = ts.emptyOptions, triggerReason: ts.RefactorTriggerReason = "implicit", kind?: string): readonly ts.ApplicableRefactorInfo[] { 3951 return this.getApplicableRefactorsWorker("position" in rangeOrMarker ? rangeOrMarker.position : rangeOrMarker, rangeOrMarker.fileName, preferences, triggerReason, kind); // eslint-disable-line local/no-in-operator 3952 } 3953 private getApplicableRefactorsWorker(positionOrRange: number | ts.TextRange, fileName: string, preferences = ts.emptyOptions, triggerReason: ts.RefactorTriggerReason, kind?: string): readonly ts.ApplicableRefactorInfo[] { 3954 return this.languageService.getApplicableRefactors(fileName, positionOrRange, preferences, triggerReason, kind) || ts.emptyArray; 3955 } 3956 3957 public configurePlugin(pluginName: string, configuration: any): void { 3958 (this.languageService as ts.server.SessionClient).configurePlugin(pluginName, configuration); 3959 } 3960 3961 public setCompilerOptionsForInferredProjects(options: ts.server.protocol.CompilerOptions) { 3962 ts.Debug.assert(this.testType === FourSlashTestType.Server); 3963 (this.languageService as ts.server.SessionClient).setCompilerOptionsForInferredProjects(options); 3964 } 3965 3966 public toggleLineComment(newFileContent: string): void { 3967 const changes: ts.TextChange[] = []; 3968 for (const range of this.getRanges()) { 3969 changes.push.apply(changes, this.languageService.toggleLineComment(this.activeFile.fileName, range)); 3970 } 3971 3972 this.applyEdits(this.activeFile.fileName, changes); 3973 3974 this.verifyCurrentFileContent(newFileContent); 3975 } 3976 3977 public toggleMultilineComment(newFileContent: string): void { 3978 const changes: ts.TextChange[] = []; 3979 for (const range of this.getRanges()) { 3980 changes.push.apply(changes, this.languageService.toggleMultilineComment(this.activeFile.fileName, range)); 3981 } 3982 3983 this.applyEdits(this.activeFile.fileName, changes); 3984 3985 this.verifyCurrentFileContent(newFileContent); 3986 } 3987 3988 public commentSelection(newFileContent: string): void { 3989 const changes: ts.TextChange[] = []; 3990 for (const range of this.getRanges()) { 3991 changes.push.apply(changes, this.languageService.commentSelection(this.activeFile.fileName, range)); 3992 } 3993 3994 this.applyEdits(this.activeFile.fileName, changes); 3995 3996 this.verifyCurrentFileContent(newFileContent); 3997 } 3998 3999 public uncommentSelection(newFileContent: string): void { 4000 const changes: ts.TextChange[] = []; 4001 for (const range of this.getRanges()) { 4002 changes.push.apply(changes, this.languageService.uncommentSelection(this.activeFile.fileName, range)); 4003 } 4004 4005 this.applyEdits(this.activeFile.fileName, changes); 4006 4007 this.verifyCurrentFileContent(newFileContent); 4008 } 4009} 4010 4011function prefixMessage(message: string | undefined) { 4012 return message ? `${message} - ` : ""; 4013} 4014 4015function textSpanEqualsRange(span: ts.TextSpan, range: Range) { 4016 return span.start === range.pos && span.length === range.end - range.pos; 4017} 4018 4019function updateTextRangeForTextChanges({ pos, end }: ts.TextRange, textChanges: readonly ts.TextChange[]): ts.TextRange { 4020 forEachTextChange(textChanges, change => { 4021 const update = (p: number): number => updatePosition(p, change.span.start, ts.textSpanEnd(change.span), change.newText); 4022 pos = update(pos); 4023 end = update(end); 4024 }); 4025 return { pos, end }; 4026} 4027 4028/** Apply each textChange in order, updating future changes to account for the text offset of previous changes. */ 4029function forEachTextChange(changes: readonly ts.TextChange[], cb: (change: ts.TextChange) => void): void { 4030 // Copy this so we don't ruin someone else's copy 4031 changes = JSON.parse(JSON.stringify(changes)); 4032 for (let i = 0; i < changes.length; i++) { 4033 const change = changes[i]; 4034 cb(change); 4035 const changeDelta = change.newText.length - change.span.length; 4036 for (let j = i + 1; j < changes.length; j++) { 4037 if (changes[j].span.start >= change.span.start) { 4038 changes[j].span.start += changeDelta; 4039 } 4040 } 4041 } 4042} 4043 4044function updatePosition(position: number, editStart: number, editEnd: number, { length }: string): number { 4045 // If inside the edit, return -1 to mark as invalid 4046 return position <= editStart ? position : position < editEnd ? -1 : position + length - + (editEnd - editStart); 4047} 4048 4049function renameKeys<T>(obj: { readonly [key: string]: T }, renameKey: (key: string) => string): { readonly [key: string]: T } { 4050 const res: { [key: string]: T } = {}; 4051 for (const key in obj) { 4052 res[renameKey(key)] = obj[key]; 4053 } 4054 return res; 4055} 4056 4057export function runFourSlashTest(basePath: string, testType: FourSlashTestType, fileName: string) { 4058 const content = Harness.IO.readFile(fileName)!; 4059 runFourSlashTestContent(basePath, testType, content, fileName); 4060} 4061 4062export function runFourSlashTestContent(basePath: string, testType: FourSlashTestType, content: string, fileName: string): void { 4063 // Give file paths an absolute path for the virtual file system 4064 const absoluteBasePath = ts.combinePaths(Harness.virtualFileSystemRoot, basePath); 4065 const absoluteFileName = ts.combinePaths(Harness.virtualFileSystemRoot, fileName); 4066 4067 // Parse out the files and their metadata 4068 const testData = parseTestData(absoluteBasePath, content, absoluteFileName); 4069 const state = new TestState(absoluteFileName, absoluteBasePath, testType, testData); 4070 const actualFileName = Harness.IO.resolvePath(fileName) || absoluteFileName; 4071 const compilerOptions: ts.CompilerOptions = { target: ts.ScriptTarget.ES2015, inlineSourceMap: true, inlineSources: true, packageManagerType: testType === FourSlashTestType.OH ? "ohpm" : "npm" }; 4072 const output = ts.transpileModule(content, { reportDiagnostics: true, fileName: actualFileName, compilerOptions }); 4073 if (output.diagnostics!.length > 0) { 4074 throw new Error(`Syntax error in ${absoluteBasePath}: ${output.diagnostics![0].messageText}`); 4075 } 4076 runCode(output.outputText, state, actualFileName); 4077} 4078 4079function runCode(code: string, state: TestState, fileName: string): void { 4080 // Compile and execute the test 4081 const generatedFile = ts.changeExtension(fileName, ".js"); 4082 const wrappedCode = `(function(ts, test, goTo, config, verify, edit, debug, format, cancellation, classification, completion, verifyOperationIsCancelled, ignoreInterpolations) {${code}\n//# sourceURL=${ts.getBaseFileName(generatedFile)}\n})`; 4083 4084 type SourceMapSupportModule = typeof import("source-map-support") & { 4085 // TODO(rbuckton): This is missing from the DT definitions and needs to be added. 4086 resetRetrieveHandlers(): void 4087 }; 4088 4089 // Provide the content of the current test to 'source-map-support' so that it can give us the correct source positions 4090 // for test failures. 4091 let sourceMapSupportModule: SourceMapSupportModule | undefined; 4092 try { 4093 sourceMapSupportModule = require("source-map-support"); 4094 } 4095 catch { 4096 // do nothing 4097 } 4098 4099 sourceMapSupportModule?.install({ 4100 retrieveFile: path => { 4101 return path === generatedFile ? wrappedCode : 4102 undefined!; 4103 } 4104 }); 4105 4106 try { 4107 const test = new FourSlashInterface.Test(state); 4108 const goTo = new FourSlashInterface.GoTo(state); 4109 const config = new FourSlashInterface.Config(state); 4110 const verify = new FourSlashInterface.Verify(state); 4111 const edit = new FourSlashInterface.Edit(state); 4112 const debug = new FourSlashInterface.Debug(state); 4113 const format = new FourSlashInterface.Format(state); 4114 const cancellation = new FourSlashInterface.Cancellation(state); 4115 // eslint-disable-next-line no-eval 4116 const f = (0, eval)(wrappedCode); 4117 f(ts, test, goTo, config, verify, edit, debug, format, cancellation, FourSlashInterface.classification, FourSlashInterface.Completion, verifyOperationIsCancelled, ignoreInterpolations); 4118 } 4119 catch (err) { 4120 // ensure 'source-map-support' is triggered while we still have the handler attached by accessing `error.stack`. 4121 err.stack?.toString(); 4122 throw err; 4123 } 4124 finally { 4125 sourceMapSupportModule?.resetRetrieveHandlers(); 4126 } 4127} 4128 4129function chompLeadingSpace(content: string) { 4130 const lines = content.split("\n"); 4131 for (const line of lines) { 4132 if ((line.length !== 0) && (line.charAt(0) !== " ")) { 4133 return content; 4134 } 4135 } 4136 4137 return lines.map(s => s.substr(1)).join("\n"); 4138} 4139 4140function parseTestData(basePath: string, contents: string, fileName: string): FourSlashData { 4141 // Regex for parsing options in the format "@Alpha: Value of any sort" 4142 const optionRegex = /^\s*@(\w+):\s*(.*)\s*/; 4143 4144 // List of all the subfiles we've parsed out 4145 const files: FourSlashFile[] = []; 4146 // Global options 4147 const globalOptions: { [s: string]: string; } = {}; 4148 let symlinks: vfs.FileSet | undefined; 4149 // Marker positions 4150 4151 // Split up the input file by line 4152 // Note: IE JS engine incorrectly handles consecutive delimiters here when using RegExp split, so 4153 // we have to string-based splitting instead and try to figure out the delimiting chars 4154 const lines = contents.split("\n"); 4155 let i = 0; 4156 4157 const markerPositions = new ts.Map<string, Marker>(); 4158 const markers: Marker[] = []; 4159 const ranges: Range[] = []; 4160 4161 // Stuff related to the subfile we're parsing 4162 let currentFileContent: string | undefined; 4163 let currentFileName = fileName; 4164 let currentFileSymlinks: string[] | undefined; 4165 let currentFileOptions: { [s: string]: string } = {}; 4166 4167 function nextFile() { 4168 if (currentFileContent === undefined) return; 4169 4170 const file = parseFileContent(currentFileContent, currentFileName, markerPositions, markers, ranges); 4171 file.fileOptions = currentFileOptions; 4172 file.symlinks = currentFileSymlinks; 4173 4174 // Store result file 4175 files.push(file); 4176 4177 currentFileContent = undefined; 4178 currentFileOptions = {}; 4179 currentFileName = fileName; 4180 currentFileSymlinks = undefined; 4181 } 4182 4183 for (let line of lines) { 4184 i++; 4185 if (line.length > 0 && line.charAt(line.length - 1) === "\r") { 4186 line = line.substr(0, line.length - 1); 4187 } 4188 4189 if (line.substr(0, 4) === "////") { 4190 const text = line.substr(4); 4191 currentFileContent = currentFileContent === undefined ? text : currentFileContent + "\n" + text; 4192 } 4193 else if (line.substr(0, 3) === "///" && currentFileContent !== undefined) { 4194 throw new Error("Three-slash line in the middle of four-slash region at line " + i); 4195 } 4196 else if (line.substr(0, 2) === "//") { 4197 const possiblySymlinks = Harness.TestCaseParser.parseSymlinkFromTest(line, symlinks); 4198 if (possiblySymlinks) { 4199 symlinks = possiblySymlinks; 4200 } 4201 else { 4202 // Comment line, check for global/file @options and record them 4203 const match = optionRegex.exec(line.substr(2)); 4204 if (match) { 4205 const key = match[1].toLowerCase(); 4206 const value = match[2]; 4207 if (!ts.contains(fileMetadataNames, key)) { 4208 // Check if the match is already existed in the global options 4209 if (globalOptions[key] !== undefined) { 4210 throw new Error(`Global option '${key}' already exists`); 4211 } 4212 globalOptions[key] = value; 4213 } 4214 else { 4215 switch (key) { 4216 case MetadataOptionNames.fileName: 4217 // Found an @FileName directive, if this is not the first then create a new subfile 4218 nextFile(); 4219 currentFileName = ts.isRootedDiskPath(value) ? value : basePath + "/" + value; 4220 currentFileOptions[key] = value; 4221 break; 4222 case MetadataOptionNames.symlink: 4223 currentFileSymlinks = ts.append(currentFileSymlinks, value); 4224 break; 4225 default: 4226 // Add other fileMetadata flag 4227 currentFileOptions[key] = value; 4228 } 4229 } 4230 } 4231 } 4232 } 4233 // Previously blank lines between fourslash content caused it to be considered as 2 files, 4234 // Remove this behavior since it just causes errors now 4235 else if (line !== "") { 4236 // Code line, terminate current subfile if there is one 4237 nextFile(); 4238 } 4239 } 4240 4241 // @Filename is the only directive that can be used in a test that contains tsconfig.json file. 4242 const config = ts.find(files, isConfig); 4243 if (config) { 4244 let directive = getNonFileNameOptionInFileList(files); 4245 if (!directive) { 4246 directive = getNonFileNameOptionInObject(globalOptions); 4247 } 4248 if (directive) { 4249 throw Error(`It is not allowed to use ${config.fileName} along with directive '${directive}'`); 4250 } 4251 } 4252 4253 return { 4254 markerPositions, 4255 markers, 4256 globalOptions, 4257 files, 4258 symlinks, 4259 ranges 4260 }; 4261} 4262 4263function isConfig(file: FourSlashFile): boolean { 4264 return Harness.getConfigNameFromFileName(file.fileName) !== undefined; 4265} 4266 4267function getNonFileNameOptionInFileList(files: FourSlashFile[]): string | undefined { 4268 return ts.forEach(files, f => getNonFileNameOptionInObject(f.fileOptions)); 4269} 4270 4271function getNonFileNameOptionInObject(optionObject: { [s: string]: string }): string | undefined { 4272 for (const option in optionObject) { 4273 switch (option) { 4274 case MetadataOptionNames.fileName: 4275 case MetadataOptionNames.baselineFile: 4276 case MetadataOptionNames.emitThisFile: 4277 break; 4278 default: 4279 return option; 4280 } 4281 } 4282 return undefined; 4283} 4284 4285const enum State { 4286 none, 4287 inSlashStarMarker, 4288 inObjectMarker 4289} 4290 4291function reportError(fileName: string, line: number, col: number, message: string) { 4292 const errorMessage = fileName + "(" + line + "," + col + "): " + message; 4293 throw new Error(errorMessage); 4294} 4295 4296function recordObjectMarker(fileName: string, location: LocationInformation, text: string, markerMap: ts.ESMap<string, Marker>, markers: Marker[]): Marker | undefined { 4297 let markerValue: any; 4298 try { 4299 // Attempt to parse the marker value as JSON 4300 markerValue = JSON.parse("{ " + text + " }"); 4301 } 4302 catch (e) { 4303 reportError(fileName, location.sourceLine, location.sourceColumn, "Unable to parse marker text " + e.message); 4304 } 4305 4306 if (markerValue === undefined) { 4307 reportError(fileName, location.sourceLine, location.sourceColumn, "Object markers can not be empty"); 4308 return undefined; 4309 } 4310 4311 const marker: Marker = { 4312 fileName, 4313 position: location.position, 4314 data: markerValue 4315 }; 4316 4317 // Object markers can be anonymous 4318 if (markerValue.name) { 4319 markerMap.set(markerValue.name, marker); 4320 } 4321 4322 markers.push(marker); 4323 4324 return marker; 4325} 4326 4327function recordMarker(fileName: string, location: LocationInformation, name: string, markerMap: ts.ESMap<string, Marker>, markers: Marker[]): Marker | undefined { 4328 const marker: Marker = { 4329 fileName, 4330 position: location.position 4331 }; 4332 4333 // Verify markers for uniqueness 4334 if (markerMap.has(name)) { 4335 const message = "Marker '" + name + "' is duplicated in the source file contents."; 4336 reportError(marker.fileName, location.sourceLine, location.sourceColumn, message); 4337 return undefined; 4338 } 4339 else { 4340 markerMap.set(name, marker); 4341 markers.push(marker); 4342 return marker; 4343 } 4344} 4345 4346function parseFileContent(content: string, fileName: string, markerMap: ts.ESMap<string, Marker>, markers: Marker[], ranges: Range[]): FourSlashFile { 4347 content = chompLeadingSpace(content); 4348 4349 // Any slash-star comment with a character not in this string is not a marker. 4350 const validMarkerChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz$1234567890_"; 4351 4352 /// The file content (minus metacharacters) so far 4353 let output = ""; 4354 4355 /// The current marker (or maybe multi-line comment?) we're parsing, possibly 4356 let openMarker: LocationInformation | undefined; 4357 4358 /// A stack of the open range markers that are still unclosed 4359 const openRanges: RangeLocationInformation[] = []; 4360 4361 /// A list of ranges we've collected so far */ 4362 let localRanges: Range[] = []; 4363 4364 /// The latest position of the start of an unflushed plain text area 4365 let lastNormalCharPosition = 0; 4366 4367 /// The total number of metacharacters removed from the file (so far) 4368 let difference = 0; 4369 4370 /// The fourslash file state object we are generating 4371 let state: State = State.none; 4372 4373 /// Current position data 4374 let line = 1; 4375 let column = 1; 4376 4377 const flush = (lastSafeCharIndex: number | undefined) => { 4378 output = output + content.substr(lastNormalCharPosition, lastSafeCharIndex === undefined ? undefined : lastSafeCharIndex - lastNormalCharPosition); 4379 }; 4380 4381 if (content.length > 0) { 4382 let previousChar = content.charAt(0); 4383 for (let i = 1; i < content.length; i++) { 4384 const currentChar = content.charAt(i); 4385 switch (state) { 4386 case State.none: 4387 if (previousChar === "[" && currentChar === "|") { 4388 // found a range start 4389 openRanges.push({ 4390 position: (i - 1) - difference, 4391 sourcePosition: i - 1, 4392 sourceLine: line, 4393 sourceColumn: column, 4394 }); 4395 // copy all text up to marker position 4396 flush(i - 1); 4397 lastNormalCharPosition = i + 1; 4398 difference += 2; 4399 } 4400 else if (previousChar === "|" && currentChar === "]") { 4401 // found a range end 4402 const rangeStart = openRanges.pop(); 4403 if (!rangeStart) { 4404 throw reportError(fileName, line, column, "Found range end with no matching start."); 4405 } 4406 4407 const range: Range = { 4408 fileName, 4409 pos: rangeStart.position, 4410 end: (i - 1) - difference, 4411 marker: rangeStart.marker 4412 }; 4413 localRanges.push(range); 4414 4415 // copy all text up to range marker position 4416 flush(i - 1); 4417 lastNormalCharPosition = i + 1; 4418 difference += 2; 4419 } 4420 else if (previousChar === "/" && currentChar === "*") { 4421 // found a possible marker start 4422 state = State.inSlashStarMarker; 4423 openMarker = { 4424 position: (i - 1) - difference, 4425 sourcePosition: i - 1, 4426 sourceLine: line, 4427 sourceColumn: column, 4428 }; 4429 } 4430 else if (previousChar === "{" && currentChar === "|") { 4431 // found an object marker start 4432 state = State.inObjectMarker; 4433 openMarker = { 4434 position: (i - 1) - difference, 4435 sourcePosition: i - 1, 4436 sourceLine: line, 4437 sourceColumn: column, 4438 }; 4439 flush(i - 1); 4440 } 4441 break; 4442 4443 case State.inObjectMarker: 4444 // Object markers are only ever terminated by |} and have no content restrictions 4445 if (previousChar === "|" && currentChar === "}") { 4446 // Record the marker 4447 const objectMarkerNameText = content.substring(openMarker!.sourcePosition + 2, i - 1).trim(); 4448 const marker = recordObjectMarker(fileName, openMarker!, objectMarkerNameText, markerMap, markers); 4449 4450 if (openRanges.length > 0) { 4451 openRanges[openRanges.length - 1].marker = marker; 4452 } 4453 4454 // Set the current start to point to the end of the current marker to ignore its text 4455 lastNormalCharPosition = i + 1; 4456 difference += i + 1 - openMarker!.sourcePosition; 4457 4458 // Reset the state 4459 openMarker = undefined; 4460 state = State.none; 4461 } 4462 break; 4463 4464 case State.inSlashStarMarker: 4465 if (previousChar === "*" && currentChar === "/") { 4466 // Record the marker 4467 // start + 2 to ignore the */, -1 on the end to ignore the * (/ is next) 4468 const markerNameText = content.substring(openMarker!.sourcePosition + 2, i - 1).trim(); 4469 const marker = recordMarker(fileName, openMarker!, markerNameText, markerMap, markers); 4470 4471 if (openRanges.length > 0) { 4472 openRanges[openRanges.length - 1].marker = marker; 4473 } 4474 4475 // Set the current start to point to the end of the current marker to ignore its text 4476 flush(openMarker!.sourcePosition); 4477 lastNormalCharPosition = i + 1; 4478 difference += i + 1 - openMarker!.sourcePosition; 4479 4480 // Reset the state 4481 openMarker = undefined; 4482 state = State.none; 4483 } 4484 else if (validMarkerChars.indexOf(currentChar) < 0) { 4485 if (currentChar === "*" && i < content.length - 1 && content.charAt(i + 1) === "/") { 4486 // The marker is about to be closed, ignore the 'invalid' char 4487 } 4488 else { 4489 // We've hit a non-valid marker character, so we were actually in a block comment 4490 // Bail out the text we've gathered so far back into the output 4491 flush(i); 4492 lastNormalCharPosition = i; 4493 openMarker = undefined; 4494 4495 state = State.none; 4496 } 4497 } 4498 break; 4499 } 4500 4501 if (currentChar === "\n" && previousChar === "\r") { 4502 // Ignore trailing \n after a \r 4503 continue; 4504 } 4505 else if (currentChar === "\n" || currentChar === "\r") { 4506 line++; 4507 column = 1; 4508 continue; 4509 } 4510 4511 column++; 4512 previousChar = currentChar; 4513 } 4514 } 4515 4516 // Add the remaining text 4517 flush(/*lastSafeCharIndex*/ undefined); 4518 4519 if (openRanges.length > 0) { 4520 const openRange = openRanges[0]; 4521 reportError(fileName, openRange.sourceLine, openRange.sourceColumn, "Unterminated range."); 4522 } 4523 4524 if (openMarker) { 4525 reportError(fileName, openMarker.sourceLine, openMarker.sourceColumn, "Unterminated marker."); 4526 } 4527 4528 // put ranges in the correct order 4529 localRanges = localRanges.sort((a, b) => a.pos < b.pos ? -1 : a.pos === b.pos && a.end > b.end ? -1 : 1); 4530 localRanges.forEach(r => ranges.push(r)); 4531 4532 return { 4533 content: output, 4534 fileOptions: {}, 4535 version: 0, 4536 fileName, 4537 }; 4538} 4539 4540function stringify(data: any, replacer?: (key: string, value: any) => any): string { 4541 return JSON.stringify(data, replacer, 2); 4542} 4543 4544/** Collects an array of unique outputs. */ 4545function unique<T>(inputs: readonly T[], getOutput: (t: T) => string): string[] { 4546 const set = new ts.Map<string, true>(); 4547 for (const input of inputs) { 4548 const out = getOutput(input); 4549 set.set(out, true); 4550 } 4551 return ts.arrayFrom(set.keys()); 4552} 4553 4554function toArray<T>(x: ArrayOrSingle<T>): readonly T[] { 4555 return ts.isArray(x) ? x : [x]; 4556} 4557 4558function makeWhitespaceVisible(text: string) { 4559 return text.replace(/ /g, "\u00B7").replace(/\r/g, "\u00B6").replace(/\n/g, "\u2193\n").replace(/\t/g, "\u2192\ "); 4560} 4561 4562function showTextDiff(expected: string, actual: string): string { 4563 // Only show whitespace if the difference is whitespace-only. 4564 if (differOnlyByWhitespace(expected, actual)) { 4565 expected = makeWhitespaceVisible(expected); 4566 actual = makeWhitespaceVisible(actual); 4567 } 4568 return displayExpectedAndActualString(expected, actual); 4569} 4570 4571function differOnlyByWhitespace(a: string, b: string) { 4572 return stripWhitespace(a) === stripWhitespace(b); 4573} 4574 4575function stripWhitespace(s: string): string { 4576 return s.replace(/\s/g, ""); 4577} 4578 4579function findDuplicatedElement<T>(a: readonly T[], equal: (a: T, b: T) => boolean): T | undefined { 4580 for (let i = 0; i < a.length; i++) { 4581 for (let j = i + 1; j < a.length; j++) { 4582 if (equal(a[i], a[j])) { 4583 return a[i]; 4584 } 4585 } 4586 } 4587} 4588 4589function displayExpectedAndActualString(expected: string, actual: string, quoted = false) { 4590 const expectMsg = "\x1b[1mExpected\x1b[0m\x1b[31m"; 4591 const actualMsg = "\x1b[1mActual\x1b[0m\x1b[31m"; 4592 const expectedString = quoted ? "\"" + expected + "\"" : expected; 4593 const actualString = quoted ? "\"" + actual + "\"" : actual; 4594 return `\n${expectMsg}:\n${expectedString}\n\n${actualMsg}:\n${highlightDifferenceBetweenStrings(expected, actualString)}`; 4595} 4596 4597function templateToRegExp(template: string) { 4598 return new RegExp(`^${ts.regExpEscape(template).replace(/\\\{\d+\\\}/g, ".*?")}$`); 4599} 4600 4601function rangesOfDiffBetweenTwoStrings(source: string, target: string) { 4602 const ranges = [] as { start: number; length: number }[]; 4603 4604 const addToIndex = (index: number) => { 4605 const closestIndex = ranges[ranges.length - 1]; 4606 if (closestIndex) { 4607 const doesAddToIndex = closestIndex.start + closestIndex.length === index - 1; 4608 if (doesAddToIndex) { 4609 closestIndex.length = closestIndex.length + 1; 4610 } 4611 else { 4612 ranges.push({ start: index - 1, length: 1 }); 4613 } 4614 } 4615 else { 4616 ranges.push({ start: index - 1, length: 1 }); 4617 } 4618 }; 4619 4620 for (let index = 0; index < Math.max(source.length, target.length); index++) { 4621 const srcChar = source[index]; 4622 const targetChar = target[index]; 4623 if (srcChar !== targetChar) addToIndex(index); 4624 } 4625 4626 return ranges; 4627 } 4628 4629 // Adds an _ when the source string and the target string have a whitespace difference 4630 function highlightDifferenceBetweenStrings(source: string, target: string) { 4631 const ranges = rangesOfDiffBetweenTwoStrings(source, target); 4632 let emTarget = target; 4633 ranges.forEach((range, index) => { 4634 const lhs = `\x1b[4m`; 4635 const rhs = `\x1b[0m\x1b[31m`; 4636 const additionalOffset = index * lhs.length + index * rhs.length; 4637 const before = emTarget.slice(0, range.start + 1 + additionalOffset); 4638 const between = emTarget.slice( 4639 range.start + 1 + additionalOffset, 4640 range.start + range.length + 1 + additionalOffset 4641 ); 4642 const after = emTarget.slice(range.start + range.length + 1 + additionalOffset, emTarget.length); 4643 emTarget = before + lhs + between + rhs + after; 4644 }); 4645 return emTarget; 4646 } 4647