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