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