@@ -50,10 +50,8 @@ namespace FourSlash {
5050 data ?: { } ;
5151 }
5252
53- export interface Range {
53+ export interface Range extends ts . TextRange {
5454 fileName : string ;
55- pos : number ;
56- end : number ;
5755 marker ?: Marker ;
5856 }
5957
@@ -1103,7 +1101,7 @@ namespace FourSlash {
11031101 return node ;
11041102 }
11051103
1106- private verifyRange ( desc : string , expected : Range , actual : ts . Node ) {
1104+ private verifyRange ( desc : string , expected : ts . TextRange , actual : ts . Node ) {
11071105 const actualStart = actual . getStart ( ) ;
11081106 const actualEnd = actual . getEnd ( ) ;
11091107 if ( actualStart !== expected . pos || actualEnd !== expected . end ) {
@@ -1713,11 +1711,8 @@ Actual: ${stringify(fullActual)}`);
17131711 }
17141712
17151713 public baselineQuickInfo ( ) {
1716- let baselineFile = this . testData . globalOptions [ MetadataOptionNames . baselineFile ] ;
1717- if ( ! baselineFile ) {
1718- baselineFile = ts . getBaseFileName ( this . activeFile . fileName ) . replace ( ts . Extension . Ts , ".baseline" ) ;
1719- }
1720-
1714+ const baselineFile = this . testData . globalOptions [ MetadataOptionNames . baselineFile ] ||
1715+ ts . getBaseFileName ( this . activeFile . fileName ) . replace ( ts . Extension . Ts , ".baseline" ) ;
17211716 Harness . Baseline . runBaseline (
17221717 baselineFile ,
17231718 stringify (
@@ -1958,18 +1953,11 @@ Actual: ${stringify(fullActual)}`);
19581953 * May be negative.
19591954 */
19601955 private applyEdits ( fileName : string , edits : ReadonlyArray < ts . TextChange > , isFormattingEdit : boolean ) : number {
1961- // We get back a set of edits, but langSvc.editScript only accepts one at a time. Use this to keep track
1962- // of the incremental offset from each edit to the next. We assume these edit ranges don't overlap
1963-
1964- // Copy this so we don't ruin someone else's copy
1965- edits = JSON . parse ( JSON . stringify ( edits ) ) ;
1966-
19671956 // Get a snapshot of the content of the file so we can make sure any formatting edits didn't destroy non-whitespace characters
19681957 const oldContent = this . getFileContent ( fileName ) ;
19691958 let runningOffset = 0 ;
19701959
1971- for ( let i = 0 ; i < edits . length ; i ++ ) {
1972- const edit = edits [ i ] ;
1960+ forEachTextChange ( edits , edit => {
19731961 const offsetStart = edit . span . start ;
19741962 const offsetEnd = offsetStart + edit . span . length ;
19751963 this . editScriptAndUpdateMarkers ( fileName , offsetStart , offsetEnd , edit . newText ) ;
@@ -1985,14 +1973,7 @@ Actual: ${stringify(fullActual)}`);
19851973 }
19861974 }
19871975 runningOffset += editDelta ;
1988-
1989- // Update positions of any future edits affected by this change
1990- for ( let j = i + 1 ; j < edits . length ; j ++ ) {
1991- if ( edits [ j ] . span . start >= edits [ i ] . span . start ) {
1992- edits [ j ] . span . start += editDelta ;
1993- }
1994- }
1995- }
1976+ } ) ;
19961977
19971978 if ( isFormattingEdit ) {
19981979 const newContent = this . getFileContent ( fileName ) ;
@@ -2034,30 +2015,14 @@ Actual: ${stringify(fullActual)}`);
20342015 this . languageServiceAdapterHost . editScript ( fileName , editStart , editEnd , newText ) ;
20352016 for ( const marker of this . testData . markers ) {
20362017 if ( marker . fileName === fileName ) {
2037- marker . position = updatePosition ( marker . position ) ;
2018+ marker . position = updatePosition ( marker . position , editStart , editEnd , newText ) ;
20382019 }
20392020 }
20402021
20412022 for ( const range of this . testData . ranges ) {
20422023 if ( range . fileName === fileName ) {
2043- range . pos = updatePosition ( range . pos ) ;
2044- range . end = updatePosition ( range . end ) ;
2045- }
2046- }
2047-
2048- function updatePosition ( position : number ) {
2049- if ( position > editStart ) {
2050- if ( position < editEnd ) {
2051- // Inside the edit - mark it as invalidated (?)
2052- return - 1 ;
2053- }
2054- else {
2055- // Move marker back/forward by the appropriate amount
2056- return position + ( editStart - editEnd ) + newText . length ;
2057- }
2058- }
2059- else {
2060- return position ;
2024+ range . pos = updatePosition ( range . pos , editStart , editEnd , newText ) ;
2025+ range . end = updatePosition ( range . end , editStart , editEnd , newText ) ;
20612026 }
20622027 }
20632028 }
@@ -2488,22 +2453,24 @@ Actual: ${stringify(fullActual)}`);
24882453
24892454 this . applyCodeActions ( codeActions ) ;
24902455
2491- this . verifyNewContent ( options , ts . flatMap ( codeActions , a => a . changes . map ( c => c . fileName ) ) ) ;
2456+ this . verifyNewContentAfterChange ( options , ts . flatMap ( codeActions , a => a . changes . map ( c => c . fileName ) ) ) ;
24922457 }
24932458
24942459 public verifyRangeIs ( expectedText : string , includeWhiteSpace ?: boolean ) {
2460+ this . verifyTextMatches ( this . rangeText ( this . getOnlyRange ( ) ) , ! ! includeWhiteSpace , expectedText ) ;
2461+ }
2462+
2463+ private getOnlyRange ( ) {
24952464 const ranges = this . getRanges ( ) ;
24962465 if ( ranges . length !== 1 ) {
24972466 this . raiseError ( "Exactly one range should be specified in the testfile." ) ;
24982467 }
2468+ return ts . first ( ranges ) ;
2469+ }
24992470
2500- const actualText = this . rangeText ( ranges [ 0 ] ) ;
2501-
2502- const result = includeWhiteSpace
2503- ? actualText === expectedText
2504- : this . removeWhitespace ( actualText ) === this . removeWhitespace ( expectedText ) ;
2505-
2506- if ( ! result ) {
2471+ private verifyTextMatches ( actualText : string , includeWhitespace : boolean , expectedText : string ) {
2472+ const removeWhitespace = ( s : string ) : string => includeWhitespace ? s : this . removeWhitespace ( s ) ;
2473+ if ( removeWhitespace ( actualText ) !== removeWhitespace ( expectedText ) ) {
25072474 this . raiseError ( `Actual range text doesn't match expected text.\n${ showTextDiff ( expectedText , actualText ) } ` ) ;
25082475 }
25092476 }
@@ -2570,33 +2537,68 @@ Actual: ${stringify(fullActual)}`);
25702537 const action = actions [ index ] ;
25712538
25722539 assert . equal ( action . description , options . description ) ;
2540+ assert . deepEqual ( action . commands , options . commands ) ;
25732541
2574- for ( const change of action . changes ) {
2575- this . applyEdits ( change . fileName , change . textChanges , /*isFormattingEdit*/ false ) ;
2542+ if ( options . applyChanges ) {
2543+ for ( const change of action . changes ) {
2544+ this . applyEdits ( change . fileName , change . textChanges , /*isFormattingEdit*/ false ) ;
2545+ }
2546+ this . verifyNewContentAfterChange ( options , action . changes . map ( c => c . fileName ) ) ;
25762547 }
2548+ else {
2549+ this . verifyNewContent ( options , action . changes ) ;
2550+ }
2551+ }
25772552
2578- this . verifyNewContent ( options , action . changes . map ( c => c . fileName ) ) ;
2553+ private verifyNewContent ( { newFileContent, newRangeContent } : FourSlashInterface . NewContentOptions , changes : ReadonlyArray < ts . FileTextChanges > ) : void {
2554+ if ( newRangeContent !== undefined ) {
2555+ assert ( newFileContent === undefined ) ;
2556+ assert ( changes . length === 1 , "Affected 0 or more than 1 file, must use 'newFileContent' instead of 'newRangeContent'" ) ;
2557+ const change = ts . first ( changes ) ;
2558+ assert ( change . fileName = this . activeFile . fileName ) ;
2559+ const newText = ts . textChanges . applyChanges ( this . getFileContent ( this . activeFile . fileName ) , change . textChanges ) ;
2560+ const newRange = updateTextRangeForTextChanges ( this . getOnlyRange ( ) , change . textChanges ) ;
2561+ const actualText = newText . slice ( newRange . pos , newRange . end ) ;
2562+ this . verifyTextMatches ( actualText , /*includeWhitespace*/ true , newRangeContent ) ;
2563+ }
2564+ else {
2565+ if ( newFileContent === undefined ) throw ts . Debug . fail ( ) ;
2566+ if ( typeof newFileContent !== "object" ) newFileContent = { [ this . activeFile . fileName ] : newFileContent } ;
2567+ for ( const change of changes ) {
2568+ const expectedNewContent = newFileContent [ change . fileName ] ;
2569+ if ( expectedNewContent === undefined ) {
2570+ ts . Debug . fail ( `Did not expect a change in ${ change . fileName } ` ) ;
2571+ }
2572+ const oldText = this . tryGetFileContent ( change . fileName ) ;
2573+ ts . Debug . assert ( ! ! change . isNewFile === ( oldText === undefined ) ) ;
2574+ const newContent = change . isNewFile ? ts . first ( change . textChanges ) . newText : ts . textChanges . applyChanges ( oldText ! , change . textChanges ) ;
2575+ assert . equal ( newContent , expectedNewContent ) ;
2576+ }
2577+ for ( const newFileName in newFileContent ) {
2578+ ts . Debug . assert ( changes . some ( c => c . fileName === newFileName ) , "No change in file" , ( ) => newFileName ) ;
2579+ }
2580+ }
25792581 }
25802582
2581- private verifyNewContent ( options : FourSlashInterface . NewContentOptions , changedFiles : ReadonlyArray < string > ) {
2582- const assertedChangedFiles = ! options . newFileContent || typeof options . newFileContent === "string"
2583+ private verifyNewContentAfterChange ( { newFileContent , newRangeContent } : FourSlashInterface . NewContentOptions , changedFiles : ReadonlyArray < string > ) {
2584+ const assertedChangedFiles = ! newFileContent || typeof newFileContent === "string"
25832585 ? [ this . activeFile . fileName ]
2584- : ts . getOwnKeys ( options . newFileContent ) ;
2586+ : ts . getOwnKeys ( newFileContent ) ;
25852587 assert . deepEqual ( assertedChangedFiles , changedFiles ) ;
25862588
2587- if ( options . newFileContent !== undefined ) {
2588- assert ( ! options . newRangeContent ) ;
2589- if ( typeof options . newFileContent === "string" ) {
2590- this . verifyCurrentFileContent ( options . newFileContent ) ;
2589+ if ( newFileContent !== undefined ) {
2590+ assert ( ! newRangeContent ) ;
2591+ if ( typeof newFileContent === "string" ) {
2592+ this . verifyCurrentFileContent ( newFileContent ) ;
25912593 }
25922594 else {
2593- for ( const fileName in options . newFileContent ) {
2594- this . verifyFileContent ( fileName , options . newFileContent [ fileName ] ) ;
2595+ for ( const fileName in newFileContent ) {
2596+ this . verifyFileContent ( fileName , newFileContent [ fileName ] ) ;
25952597 }
25962598 }
25972599 }
25982600 else {
2599- this . verifyRangeIs ( options . newRangeContent ! , /*includeWhitespace*/ true ) ;
2601+ this . verifyRangeIs ( newRangeContent ! , /*includeWhitespace*/ true ) ;
26002602 }
26012603 }
26022604
@@ -3114,7 +3116,7 @@ Actual: ${stringify(fullActual)}`);
31143116 assert ( action . name === "Move to a new file" && action . description === "Move to a new file" ) ;
31153117
31163118 const editInfo = this . languageService . getEditsForRefactor ( range . fileName , this . formatCodeSettings , range , refactor . name , action . name , options . preferences || ts . emptyOptions ) ! ;
3117- this . testNewFileContents ( editInfo . edits , options . newFileContents , "move to new file" ) ;
3119+ this . verifyNewContent ( { newFileContent : options . newFileContents } , editInfo . edits ) ;
31183120 }
31193121
31203122 private testNewFileContents ( edits : ReadonlyArray < ts . FileTextChanges > , newFileContents : { [ fileName : string ] : string } , description : string ) : void {
@@ -3380,6 +3382,36 @@ Actual: ${stringify(fullActual)}`);
33803382 }
33813383 }
33823384
3385+ function updateTextRangeForTextChanges ( { pos, end } : ts . TextRange , textChanges : ReadonlyArray < ts . TextChange > ) : ts . TextRange {
3386+ forEachTextChange ( textChanges , change => {
3387+ const update = ( p : number ) : number => updatePosition ( p , change . span . start , ts . textSpanEnd ( change . span ) , change . newText ) ;
3388+ pos = update ( pos ) ;
3389+ end = update ( end ) ;
3390+ } ) ;
3391+ return { pos, end } ;
3392+ }
3393+
3394+ /** Apply each textChange in order, updating future changes to account for the text offset of previous changes. */
3395+ function forEachTextChange ( changes : ReadonlyArray < ts . TextChange > , cb : ( change : ts . TextChange ) => void ) : void {
3396+ // Copy this so we don't ruin someone else's copy
3397+ changes = JSON . parse ( JSON . stringify ( changes ) ) ;
3398+ for ( let i = 0 ; i < changes . length ; i ++ ) {
3399+ const change = changes [ i ] ;
3400+ cb ( change ) ;
3401+ const changeDelta = change . newText . length - change . span . length ;
3402+ for ( let j = i + 1 ; j < changes . length ; j ++ ) {
3403+ if ( changes [ j ] . span . start >= change . span . start ) {
3404+ changes [ j ] . span . start += changeDelta ;
3405+ }
3406+ }
3407+ }
3408+ }
3409+
3410+ function updatePosition ( position : number , editStart : number , editEnd : number , { length } : string ) : number {
3411+ // If inside the edit, return -1 to mark as invalid
3412+ return position <= editStart ? position : position < editEnd ? - 1 : position + length - + ( editEnd - editStart ) ;
3413+ }
3414+
33833415 function renameKeys < T > ( obj : { readonly [ key : string ] : T } , renameKey : ( key : string ) => string ) : { readonly [ key : string ] : T } {
33843416 const res : { [ key : string ] : T } = { } ;
33853417 for ( const key in obj ) {
@@ -4842,10 +4874,12 @@ namespace FourSlashInterface {
48424874 }
48434875
48444876 export interface VerifyCodeFixOptions extends NewContentOptions {
4845- description : string ;
4846- errorCode ?: number ;
4847- index ?: number ;
4848- preferences ?: ts . UserPreferences ;
4877+ readonly description : string ;
4878+ readonly errorCode ?: number ;
4879+ readonly index ?: number ;
4880+ readonly preferences ?: ts . UserPreferences ;
4881+ readonly applyChanges ?: boolean ;
4882+ readonly commands ?: ReadonlyArray < ts . CodeActionCommand > ;
48494883 }
48504884
48514885 export interface VerifyCodeFixAvailableOptions {
0 commit comments