Skip to content

Commit be8fb98

Browse files
authored
Implementing copy/paste (#57262)
1 parent dcec37e commit be8fb98

39 files changed

+5500
-10
lines changed

src/harness/client.ts

+22
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ import {
4848
notImplemented,
4949
OrganizeImportsArgs,
5050
OutliningSpan,
51+
PasteEdits,
52+
PasteEditsArgs,
5153
PatternMatchKind,
5254
Program,
5355
QuickInfo,
@@ -1006,6 +1008,26 @@ export class SessionClient implements LanguageService {
10061008
return getSupportedCodeFixes();
10071009
}
10081010

1011+
getPasteEdits(
1012+
{ targetFile, pastedText, pasteLocations, copiedFrom }: PasteEditsArgs,
1013+
formatOptions: FormatCodeSettings,
1014+
): PasteEdits {
1015+
this.setFormattingOptions(formatOptions);
1016+
const args: protocol.GetPasteEditsRequestArgs = {
1017+
file: targetFile,
1018+
pastedText,
1019+
pasteLocations: pasteLocations.map(range => ({ start: this.positionToOneBasedLineOffset(targetFile, range.pos), end: this.positionToOneBasedLineOffset(targetFile, range.end) })),
1020+
copiedFrom: copiedFrom ? { file: copiedFrom.file, spans: copiedFrom.range.map(range => ({ start: this.positionToOneBasedLineOffset(copiedFrom.file, range.pos), end: this.positionToOneBasedLineOffset(copiedFrom.file, range.end) })) } : undefined,
1021+
};
1022+
const request = this.processRequest<protocol.GetPasteEditsRequest>(protocol.CommandTypes.GetPasteEdits, args);
1023+
const response = this.processResponse<protocol.GetPasteEditsResponse>(request);
1024+
if (!response.body) {
1025+
return { edits: [] };
1026+
}
1027+
const edits: FileTextChanges[] = this.convertCodeEditsToTextChanges(response.body.edits);
1028+
return { edits, fixId: response.body.fixId };
1029+
}
1030+
10091031
getProgram(): Program {
10101032
throw new Error("Program objects are not serializable through the server protocol.");
10111033
}

src/harness/fourslashImpl.ts

+5
Original file line numberDiff line numberDiff line change
@@ -3560,6 +3560,11 @@ export class TestState {
35603560
assert.deepEqual(actualModuleSpecifiers, moduleSpecifiers);
35613561
}
35623562

3563+
public verifyPasteEdits(options: FourSlashInterface.PasteEditsOptions): void {
3564+
const editInfo = this.languageService.getPasteEdits({ targetFile: this.activeFile.fileName, pastedText: options.args.pastedText, pasteLocations: options.args.pasteLocations, copiedFrom: options.args.copiedFrom, preferences: options.args.preferences }, this.formatCodeSettings);
3565+
this.verifyNewContent({ newFileContent: options.newFileContents }, editInfo.edits);
3566+
}
3567+
35633568
public verifyDocCommentTemplate(expected: ts.TextInsertion | undefined, options?: ts.DocCommentTemplateOptions) {
35643569
const name = "verifyDocCommentTemplate";
35653570
const actual = this.languageService.getDocCommentTemplateAtPosition(this.activeFile.fileName, this.currentCaretPosition, options || { generateReturnInDocTemplate: true }, this.formatCodeSettings)!;

src/harness/fourslashInterfaceImpl.ts

+10
Original file line numberDiff line numberDiff line change
@@ -620,6 +620,10 @@ export class Verify extends VerifyNegatable {
620620
public organizeImports(newContent: string, mode?: ts.OrganizeImportsMode, preferences?: ts.UserPreferences): void {
621621
this.state.verifyOrganizeImports(newContent, mode, preferences);
622622
}
623+
624+
public pasteEdits(options: PasteEditsOptions): void {
625+
this.state.verifyPasteEdits(options);
626+
}
623627
}
624628

625629
export class Edit {
@@ -1923,6 +1927,12 @@ export interface MoveToFileOptions {
19231927
readonly preferences?: ts.UserPreferences;
19241928
}
19251929

1930+
export interface PasteEditsOptions {
1931+
readonly newFileContents: { readonly [fileName: string]: string; };
1932+
args: ts.PasteEditsArgs;
1933+
readonly fixId: string;
1934+
}
1935+
19261936
export type RenameLocationsOptions = readonly RenameLocationOptions[] | {
19271937
readonly findInStrings?: boolean;
19281938
readonly findInComments?: boolean;

src/server/project.ts

+12
Original file line numberDiff line numberDiff line change
@@ -2238,6 +2238,18 @@ export abstract class Project implements LanguageServiceHost, ModuleResolutionHo
22382238
return this.noDtsResolutionProject;
22392239
}
22402240

2241+
/** @internal */
2242+
runWithTemporaryFileUpdate(rootFile: string, updatedText: string, cb: (updatedProgram: Program, originalProgram: Program | undefined, updatedFile: SourceFile) => void) {
2243+
const originalProgram = this.program;
2244+
const originalText = this.program?.getSourceFile(rootFile)?.getText();
2245+
Debug.assert(this.program && this.program.getSourceFile(rootFile) && originalText);
2246+
2247+
this.getScriptInfo(rootFile)?.editContent(0, this.program.getSourceFile(rootFile)!.getText().length, updatedText);
2248+
this.updateGraph();
2249+
cb(this.program, originalProgram, (this.program?.getSourceFile(rootFile))!);
2250+
this.getScriptInfo(rootFile)?.editContent(0, this.program.getSourceFile(rootFile)!.getText().length, originalText);
2251+
}
2252+
22412253
/** @internal */
22422254
private getCompilerOptionsForNoDtsResolutionProject() {
22432255
return {

src/server/protocol.ts

+30
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ export const enum CommandTypes {
161161
GetApplicableRefactors = "getApplicableRefactors",
162162
GetEditsForRefactor = "getEditsForRefactor",
163163
GetMoveToRefactoringFileSuggestions = "getMoveToRefactoringFileSuggestions",
164+
GetPasteEdits = "getPasteEdits",
164165
/** @internal */
165166
GetEditsForRefactorFull = "getEditsForRefactor-full",
166167

@@ -625,6 +626,35 @@ export interface GetMoveToRefactoringFileSuggestions extends Response {
625626
};
626627
}
627628

629+
/**
630+
* Request refactorings at a given position post pasting text from some other location.
631+
*/
632+
633+
export interface GetPasteEditsRequest extends Request {
634+
command: CommandTypes.GetPasteEdits;
635+
arguments: GetPasteEditsRequestArgs;
636+
}
637+
638+
export interface GetPasteEditsRequestArgs extends FileRequestArgs {
639+
/** The text that gets pasted in a file. */
640+
pastedText: string[];
641+
/** Locations of where the `pastedText` gets added in a file. If the length of the `pastedText` and `pastedLocations` are not the same,
642+
* then the `pastedText` is combined into one and added at all the `pastedLocations`.
643+
*/
644+
pasteLocations: TextSpan[];
645+
/** The source location of each `pastedText`. If present, the length of `spans` must be equal to the length of `pastedText`. */
646+
copiedFrom?: { file: string; spans: TextSpan[]; };
647+
}
648+
649+
export interface GetPasteEditsResponse extends Response {
650+
body: PasteEditsAction;
651+
}
652+
653+
export interface PasteEditsAction {
654+
edits: FileCodeEdits[];
655+
fixId?: {};
656+
}
657+
628658
export interface GetEditsForRefactorRequest extends Request {
629659
command: CommandTypes.GetEditsForRefactor;
630660
arguments: GetEditsForRefactorRequestArgs;

src/server/session.ts

+27
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ import {
100100
OperationCanceledException,
101101
OrganizeImportsMode,
102102
OutliningSpan,
103+
PasteEdits,
103104
Path,
104105
perfLogger,
105106
PerformanceEvent,
@@ -910,6 +911,7 @@ const invalidPartialSemanticModeCommands: readonly protocol.CommandTypes[] = [
910911
protocol.CommandTypes.PrepareCallHierarchy,
911912
protocol.CommandTypes.ProvideCallHierarchyIncomingCalls,
912913
protocol.CommandTypes.ProvideCallHierarchyOutgoingCalls,
914+
protocol.CommandTypes.GetPasteEdits,
913915
];
914916

915917
const invalidSyntacticModeCommands: readonly protocol.CommandTypes[] = [
@@ -2796,6 +2798,24 @@ export class Session<TMessage = string> implements EventSender {
27962798
return project.getLanguageService().getMoveToRefactoringFileSuggestions(file, this.extractPositionOrRange(args, scriptInfo), this.getPreferences(file));
27972799
}
27982800

2801+
private getPasteEdits(args: protocol.GetPasteEditsRequestArgs): protocol.PasteEditsAction | undefined {
2802+
const { file, project } = this.getFileAndProject(args);
2803+
const copiedFrom = args.copiedFrom
2804+
? { file: args.copiedFrom.file, range: args.copiedFrom.spans.map(copies => this.getRange({ file: args.copiedFrom!.file, startLine: copies.start.line, startOffset: copies.start.offset, endLine: copies.end.line, endOffset: copies.end.offset }, project.getScriptInfoForNormalizedPath(toNormalizedPath(args.copiedFrom!.file))!)) }
2805+
: undefined;
2806+
const result = project.getLanguageService().getPasteEdits(
2807+
{
2808+
targetFile: file,
2809+
pastedText: args.pastedText,
2810+
pasteLocations: args.pasteLocations.map(paste => this.getRange({ file, startLine: paste.start.line, startOffset: paste.start.offset, endLine: paste.end.line, endOffset: paste.end.offset }, project.getScriptInfoForNormalizedPath(file)!)),
2811+
copiedFrom,
2812+
preferences: this.getPreferences(file),
2813+
},
2814+
this.getFormatOptions(file),
2815+
);
2816+
return result && this.mapPasteEditsAction(result);
2817+
}
2818+
27992819
private organizeImports(args: protocol.OrganizeImportsRequestArgs, simplifiedResult: boolean): readonly protocol.FileCodeEdits[] | readonly FileTextChanges[] {
28002820
Debug.assert(args.scope.type === "file");
28012821
const { file, project } = this.getFileAndProject(args.scope.args);
@@ -2928,6 +2948,10 @@ export class Session<TMessage = string> implements EventSender {
29282948
return { fixName, description, changes: this.mapTextChangesToCodeEdits(changes), commands, fixId, fixAllDescription };
29292949
}
29302950

2951+
private mapPasteEditsAction({ edits, fixId }: PasteEdits): protocol.PasteEditsAction {
2952+
return { edits: this.mapTextChangesToCodeEdits(edits), fixId };
2953+
}
2954+
29312955
private mapTextChangesToCodeEdits(textChanges: readonly FileTextChanges[]): protocol.FileCodeEdits[] {
29322956
return textChanges.map(change => this.mapTextChangeToCodeEdit(change));
29332957
}
@@ -3521,6 +3545,9 @@ export class Session<TMessage = string> implements EventSender {
35213545
[protocol.CommandTypes.GetMoveToRefactoringFileSuggestions]: (request: protocol.GetMoveToRefactoringFileSuggestionsRequest) => {
35223546
return this.requiredResponse(this.getMoveToRefactoringFileSuggestions(request.arguments));
35233547
},
3548+
[protocol.CommandTypes.GetPasteEdits]: (request: protocol.GetPasteEditsRequest) => {
3549+
return this.requiredResponse(this.getPasteEdits(request.arguments));
3550+
},
35243551
[protocol.CommandTypes.GetEditsForRefactorFull]: (request: protocol.GetEditsForRefactorRequest) => {
35253552
return this.requiredResponse(this.getEditsForRefactor(request.arguments, /*simplifiedResult*/ false));
35263553
},
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from "../pasteEdits.js";

src/services/_namespaces/ts.ts

+2
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,5 @@ import * as textChanges from "./ts.textChanges.js";
5656
export { textChanges };
5757
import * as formatting from "./ts.formatting.js";
5858
export { formatting };
59+
import * as pasteEdits from "./ts.PasteEdits.js";
60+
export { pasteEdits };

src/services/codefixes/importFixes.ts

+2-6
Original file line numberDiff line numberDiff line change
@@ -887,10 +887,6 @@ function getSingleExportInfoForSymbol(symbol: Symbol, symbolName: string, module
887887
}
888888
}
889889

890-
function isFutureSymbolExportInfoArray(info: readonly SymbolExportInfo[] | readonly FutureSymbolExportInfo[]): info is readonly FutureSymbolExportInfo[] {
891-
return info[0].symbol === undefined;
892-
}
893-
894890
function getImportFixes(
895891
exportInfos: readonly SymbolExportInfo[] | readonly FutureSymbolExportInfo[],
896892
usagePosition: number | undefined,
@@ -904,7 +900,7 @@ function getImportFixes(
904900
fromCacheOnly?: boolean,
905901
): { computedWithoutCacheCount: number; fixes: readonly ImportFixWithModuleSpecifier[]; } {
906902
const checker = program.getTypeChecker();
907-
const existingImports = importMap && !isFutureSymbolExportInfoArray(exportInfos) ? flatMap(exportInfos, importMap.getImportsForExportInfo) : emptyArray;
903+
const existingImports = importMap ? flatMap(exportInfos, importMap.getImportsForExportInfo) : emptyArray;
908904
const useNamespace = usagePosition !== undefined && tryUseExistingNamespaceImport(existingImports, usagePosition);
909905
const addToExisting = tryAddToExistingImport(existingImports, isValidTypeOnlyUseSite, checker, program.getCompilerOptions());
910906
if (addToExisting) {
@@ -1086,7 +1082,7 @@ function createExistingImportMap(importingFile: SourceFile, program: Program) {
10861082
}
10871083

10881084
return {
1089-
getImportsForExportInfo: ({ moduleSymbol, exportKind, targetFlags, symbol }: SymbolExportInfo): readonly FixAddToExistingImportInfo[] => {
1085+
getImportsForExportInfo: ({ moduleSymbol, exportKind, targetFlags, symbol }: SymbolExportInfo | FutureSymbolExportInfo): readonly FixAddToExistingImportInfo[] => {
10901086
const matchingDeclarations = importMap?.get(getSymbolId(moduleSymbol));
10911087
if (!matchingDeclarations) return emptyArray;
10921088

src/services/pasteEdits.ts

+115
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { addRange } from "../compiler/core.js";
2+
import {
3+
CancellationToken,
4+
Program,
5+
SourceFile,
6+
Statement,
7+
SymbolFlags,
8+
TextRange,
9+
UserPreferences,
10+
} from "../compiler/types.js";
11+
import { getLineOfLocalPosition } from "../compiler/utilities.js";
12+
import {
13+
codefix,
14+
Debug,
15+
fileShouldUseJavaScriptRequire,
16+
forEachChild,
17+
formatting,
18+
getQuotePreference,
19+
isIdentifier,
20+
textChanges,
21+
} from "./_namespaces/ts.js";
22+
import { addTargetFileImports } from "./refactors/helpers.js";
23+
import {
24+
addExportsInOldFile,
25+
getExistingLocals,
26+
getUsageInfo,
27+
} from "./refactors/moveToFile.js";
28+
import {
29+
CodeFixContextBase,
30+
FileTextChanges,
31+
LanguageServiceHost,
32+
PasteEdits,
33+
} from "./types.js";
34+
35+
const fixId = "providePostPasteEdits";
36+
/** @internal */
37+
export function pasteEditsProvider(
38+
targetFile: SourceFile,
39+
pastedText: string[],
40+
pasteLocations: TextRange[],
41+
copiedFrom: { file: SourceFile; range: TextRange[]; } | undefined,
42+
host: LanguageServiceHost,
43+
preferences: UserPreferences,
44+
formatContext: formatting.FormatContext,
45+
cancellationToken: CancellationToken,
46+
): PasteEdits {
47+
const changes: FileTextChanges[] = textChanges.ChangeTracker.with({ host, formatContext, preferences }, changeTracker => pasteEdits(targetFile, pastedText, pasteLocations, copiedFrom, host, preferences, formatContext, cancellationToken, changeTracker));
48+
return { edits: changes, fixId };
49+
}
50+
51+
function pasteEdits(
52+
targetFile: SourceFile,
53+
pastedText: string[],
54+
pasteLocations: TextRange[],
55+
copiedFrom: { file: SourceFile; range: TextRange[]; } | undefined,
56+
host: LanguageServiceHost,
57+
preferences: UserPreferences,
58+
formatContext: formatting.FormatContext,
59+
cancellationToken: CancellationToken,
60+
changes: textChanges.ChangeTracker,
61+
) {
62+
let actualPastedText: string[] | undefined;
63+
if (pastedText.length !== pasteLocations.length) {
64+
actualPastedText = pastedText.length === 1 ? pastedText : [pastedText.join("\n")];
65+
}
66+
pasteLocations.forEach((paste, i) => {
67+
changes.replaceRangeWithText(
68+
targetFile,
69+
{ pos: paste.pos, end: paste.end },
70+
actualPastedText ?
71+
actualPastedText[0] : pastedText[i],
72+
);
73+
});
74+
75+
const statements: Statement[] = [];
76+
77+
let newText = targetFile.text;
78+
for (let i = pasteLocations.length - 1; i >= 0; i--) {
79+
const { pos, end } = pasteLocations[i];
80+
newText = actualPastedText ? newText.slice(0, pos) + actualPastedText[0] + newText.slice(end) : newText.slice(0, pos) + pastedText[i] + newText.slice(end);
81+
}
82+
83+
Debug.checkDefined(host.runWithTemporaryFileUpdate).call(host, targetFile.fileName, newText, (updatedProgram: Program, originalProgram: Program | undefined, updatedFile: SourceFile) => {
84+
const importAdder = codefix.createImportAdder(updatedFile, updatedProgram, preferences, host);
85+
if (copiedFrom?.range) {
86+
Debug.assert(copiedFrom.range.length === pastedText.length);
87+
copiedFrom.range.forEach(copy => {
88+
addRange(statements, copiedFrom.file.statements, getLineOfLocalPosition(copiedFrom.file, copy.pos), getLineOfLocalPosition(copiedFrom.file, copy.end) + 1);
89+
});
90+
const usage = getUsageInfo(copiedFrom.file, statements, originalProgram!.getTypeChecker(), getExistingLocals(updatedFile, statements, originalProgram!.getTypeChecker()));
91+
Debug.assertIsDefined(originalProgram);
92+
const useEsModuleSyntax = !fileShouldUseJavaScriptRequire(targetFile.fileName, originalProgram, host, !!copiedFrom.file.commonJsModuleIndicator);
93+
addExportsInOldFile(copiedFrom.file, usage.targetFileImportsFromOldFile, changes, useEsModuleSyntax);
94+
addTargetFileImports(copiedFrom.file, usage.oldImportsNeededByTargetFile, usage.targetFileImportsFromOldFile, originalProgram.getTypeChecker(), updatedProgram, importAdder);
95+
}
96+
else {
97+
const context: CodeFixContextBase = {
98+
sourceFile: updatedFile,
99+
program: originalProgram!,
100+
cancellationToken,
101+
host,
102+
preferences,
103+
formatContext,
104+
};
105+
forEachChild(updatedFile, function cb(node) {
106+
if (isIdentifier(node) && !originalProgram?.getTypeChecker().resolveName(node.text, node, SymbolFlags.All, /*excludeGlobals*/ false)) {
107+
// generate imports
108+
importAdder.addImportForUnresolvedIdentifier(context, node, /*useAutoImportProvider*/ true);
109+
}
110+
node.forEachChild(cb);
111+
});
112+
}
113+
importAdder.writeFixes(changes, getQuotePreference(copiedFrom ? copiedFrom.file : targetFile, preferences));
114+
});
115+
}

src/services/refactors/moveToFile.ts

+4-2
Original file line numberDiff line numberDiff line change
@@ -308,7 +308,8 @@ export function deleteUnusedOldImports(oldFile: SourceFile, toMove: readonly Sta
308308
}
309309
}
310310

311-
function addExportsInOldFile(oldFile: SourceFile, targetFileImportsFromOldFile: Map<Symbol, boolean>, changes: textChanges.ChangeTracker, useEsModuleSyntax: boolean) {
311+
/** @internal */
312+
export function addExportsInOldFile(oldFile: SourceFile, targetFileImportsFromOldFile: Map<Symbol, boolean>, changes: textChanges.ChangeTracker, useEsModuleSyntax: boolean) {
312313
const markSeenTop = nodeSeenTracker(); // Needed because multiple declarations may appear in `const x = 0, y = 1;`.
313314
targetFileImportsFromOldFile.forEach((_, symbol) => {
314315
if (!symbol.declarations) {
@@ -1119,7 +1120,8 @@ function getOverloadRangeToMove(sourceFile: SourceFile, statement: Statement) {
11191120
return undefined;
11201121
}
11211122

1122-
function getExistingLocals(sourceFile: SourceFile, statements: readonly Statement[], checker: TypeChecker) {
1123+
/** @internal */
1124+
export function getExistingLocals(sourceFile: SourceFile, statements: readonly Statement[], checker: TypeChecker) {
11231125
const existingLocals = new Set<Symbol>();
11241126
for (const moduleSpecifier of sourceFile.imports) {
11251127
const declaration = importFromModuleSpecifier(moduleSpecifier);

0 commit comments

Comments
 (0)