Skip to content

Commit 194ffb3

Browse files
author
Andy
authored
fourslash: Allow to verify textChanges without changing file content (#26607)
1 parent f945eb9 commit 194ffb3

File tree

6 files changed

+116
-72
lines changed

6 files changed

+116
-72
lines changed

src/harness/fourslash.ts

Lines changed: 104 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -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 {

tests/cases/fourslash/codeFixForgottenThisPropertyAccess_static.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ verify.codeFix({
1212
`class C {
1313
static m() { C.m(); }
1414
n() { m(); }
15-
}`
15+
}`,
16+
applyChanges: true,
1617
});
1718

1819
verify.codeFix({

tests/cases/fourslash/codeFixUndeclaredInStaticMethod.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ verify.codeFix({
2424
throw new Error("Method not implemented.");
2525
}
2626
}`,
27+
applyChanges: true,
2728
});
2829

2930
verify.codeFix({
@@ -44,6 +45,7 @@ verify.codeFix({
4445
throw new Error("Method not implemented.");
4546
}
4647
}`,
48+
applyChanges: true,
4749
});
4850

4951
verify.codeFix({
@@ -65,6 +67,7 @@ verify.codeFix({
6567
throw new Error("Method not implemented.");
6668
}
6769
}`,
70+
applyChanges: true,
6871
});
6972

7073
verify.codeFix({
@@ -87,4 +90,5 @@ verify.codeFix({
8790
throw new Error("Method not implemented.");
8891
}
8992
}`,
93+
applyChanges: true,
9094
});

tests/cases/fourslash/codeFixUndeclaredMethod.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ verify.codeFix({
2626
this.foo3<1,2,3,4,5,6,7,8>();
2727
}
2828
}`,
29+
applyChanges: true,
2930
});
3031

3132
verify.codeFix({
@@ -46,7 +47,8 @@ verify.codeFix({
4647
// 8 type args
4748
this.foo3<1,2,3,4,5,6,7,8>();
4849
}
49-
}`
50+
}`,
51+
applyChanges: true,
5052
});
5153

5254
verify.codeFix({

tests/cases/fourslash/codeFixUndeclaredMethodFunctionArgs.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ verify.codeFix({
1515
throw new Error("Method not implemented.");
1616
}
1717
`,
18+
applyChanges: true,
1819
});
1920

2021
verify.codeFix({

tests/cases/fourslash/fourslash.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,8 @@ declare namespace FourSlashInterface {
183183
errorCode?: number,
184184
index?: number,
185185
preferences?: UserPreferences,
186+
applyChanges?: boolean,
187+
commands?: {}[],
186188
});
187189
codeFixAvailable(options?: ReadonlyArray<VerifyCodeFixAvailableOptions>): void;
188190
applicableRefactorAvailableAtMarker(markerName: string): void;

0 commit comments

Comments
 (0)