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