Skip to content

Commit

Permalink
Implementing copy/paste (microsoft#57262)
Browse files Browse the repository at this point in the history
  • Loading branch information
navya9singh authored May 8, 2024
1 parent dcec37e commit be8fb98
Show file tree
Hide file tree
Showing 39 changed files with 5,500 additions and 10 deletions.
22 changes: 22 additions & 0 deletions src/harness/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ import {
notImplemented,
OrganizeImportsArgs,
OutliningSpan,
PasteEdits,
PasteEditsArgs,
PatternMatchKind,
Program,
QuickInfo,
Expand Down Expand Up @@ -1006,6 +1008,26 @@ export class SessionClient implements LanguageService {
return getSupportedCodeFixes();
}

getPasteEdits(
{ targetFile, pastedText, pasteLocations, copiedFrom }: PasteEditsArgs,
formatOptions: FormatCodeSettings,
): PasteEdits {
this.setFormattingOptions(formatOptions);
const args: protocol.GetPasteEditsRequestArgs = {
file: targetFile,
pastedText,
pasteLocations: pasteLocations.map(range => ({ start: this.positionToOneBasedLineOffset(targetFile, range.pos), end: this.positionToOneBasedLineOffset(targetFile, range.end) })),
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,
};
const request = this.processRequest<protocol.GetPasteEditsRequest>(protocol.CommandTypes.GetPasteEdits, args);
const response = this.processResponse<protocol.GetPasteEditsResponse>(request);
if (!response.body) {
return { edits: [] };
}
const edits: FileTextChanges[] = this.convertCodeEditsToTextChanges(response.body.edits);
return { edits, fixId: response.body.fixId };
}

getProgram(): Program {
throw new Error("Program objects are not serializable through the server protocol.");
}
Expand Down
5 changes: 5 additions & 0 deletions src/harness/fourslashImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3560,6 +3560,11 @@ export class TestState {
assert.deepEqual(actualModuleSpecifiers, moduleSpecifiers);
}

public verifyPasteEdits(options: FourSlashInterface.PasteEditsOptions): void {
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);
this.verifyNewContent({ newFileContent: options.newFileContents }, editInfo.edits);
}

public verifyDocCommentTemplate(expected: ts.TextInsertion | undefined, options?: ts.DocCommentTemplateOptions) {
const name = "verifyDocCommentTemplate";
const actual = this.languageService.getDocCommentTemplateAtPosition(this.activeFile.fileName, this.currentCaretPosition, options || { generateReturnInDocTemplate: true }, this.formatCodeSettings)!;
Expand Down
10 changes: 10 additions & 0 deletions src/harness/fourslashInterfaceImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -620,6 +620,10 @@ export class Verify extends VerifyNegatable {
public organizeImports(newContent: string, mode?: ts.OrganizeImportsMode, preferences?: ts.UserPreferences): void {
this.state.verifyOrganizeImports(newContent, mode, preferences);
}

public pasteEdits(options: PasteEditsOptions): void {
this.state.verifyPasteEdits(options);
}
}

export class Edit {
Expand Down Expand Up @@ -1923,6 +1927,12 @@ export interface MoveToFileOptions {
readonly preferences?: ts.UserPreferences;
}

export interface PasteEditsOptions {
readonly newFileContents: { readonly [fileName: string]: string; };
args: ts.PasteEditsArgs;
readonly fixId: string;
}

export type RenameLocationsOptions = readonly RenameLocationOptions[] | {
readonly findInStrings?: boolean;
readonly findInComments?: boolean;
Expand Down
12 changes: 12 additions & 0 deletions src/server/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2238,6 +2238,18 @@ export abstract class Project implements LanguageServiceHost, ModuleResolutionHo
return this.noDtsResolutionProject;
}

/** @internal */
runWithTemporaryFileUpdate(rootFile: string, updatedText: string, cb: (updatedProgram: Program, originalProgram: Program | undefined, updatedFile: SourceFile) => void) {
const originalProgram = this.program;
const originalText = this.program?.getSourceFile(rootFile)?.getText();
Debug.assert(this.program && this.program.getSourceFile(rootFile) && originalText);

this.getScriptInfo(rootFile)?.editContent(0, this.program.getSourceFile(rootFile)!.getText().length, updatedText);
this.updateGraph();
cb(this.program, originalProgram, (this.program?.getSourceFile(rootFile))!);
this.getScriptInfo(rootFile)?.editContent(0, this.program.getSourceFile(rootFile)!.getText().length, originalText);
}

/** @internal */
private getCompilerOptionsForNoDtsResolutionProject() {
return {
Expand Down
30 changes: 30 additions & 0 deletions src/server/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ export const enum CommandTypes {
GetApplicableRefactors = "getApplicableRefactors",
GetEditsForRefactor = "getEditsForRefactor",
GetMoveToRefactoringFileSuggestions = "getMoveToRefactoringFileSuggestions",
GetPasteEdits = "getPasteEdits",
/** @internal */
GetEditsForRefactorFull = "getEditsForRefactor-full",

Expand Down Expand Up @@ -625,6 +626,35 @@ export interface GetMoveToRefactoringFileSuggestions extends Response {
};
}

/**
* Request refactorings at a given position post pasting text from some other location.
*/

export interface GetPasteEditsRequest extends Request {
command: CommandTypes.GetPasteEdits;
arguments: GetPasteEditsRequestArgs;
}

export interface GetPasteEditsRequestArgs extends FileRequestArgs {
/** The text that gets pasted in a file. */
pastedText: string[];
/** Locations of where the `pastedText` gets added in a file. If the length of the `pastedText` and `pastedLocations` are not the same,
* then the `pastedText` is combined into one and added at all the `pastedLocations`.
*/
pasteLocations: TextSpan[];
/** The source location of each `pastedText`. If present, the length of `spans` must be equal to the length of `pastedText`. */
copiedFrom?: { file: string; spans: TextSpan[]; };
}

export interface GetPasteEditsResponse extends Response {
body: PasteEditsAction;
}

export interface PasteEditsAction {
edits: FileCodeEdits[];
fixId?: {};
}

export interface GetEditsForRefactorRequest extends Request {
command: CommandTypes.GetEditsForRefactor;
arguments: GetEditsForRefactorRequestArgs;
Expand Down
27 changes: 27 additions & 0 deletions src/server/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ import {
OperationCanceledException,
OrganizeImportsMode,
OutliningSpan,
PasteEdits,
Path,
perfLogger,
PerformanceEvent,
Expand Down Expand Up @@ -910,6 +911,7 @@ const invalidPartialSemanticModeCommands: readonly protocol.CommandTypes[] = [
protocol.CommandTypes.PrepareCallHierarchy,
protocol.CommandTypes.ProvideCallHierarchyIncomingCalls,
protocol.CommandTypes.ProvideCallHierarchyOutgoingCalls,
protocol.CommandTypes.GetPasteEdits,
];

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

private getPasteEdits(args: protocol.GetPasteEditsRequestArgs): protocol.PasteEditsAction | undefined {
const { file, project } = this.getFileAndProject(args);
const copiedFrom = args.copiedFrom
? { 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))!)) }
: undefined;
const result = project.getLanguageService().getPasteEdits(
{
targetFile: file,
pastedText: args.pastedText,
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)!)),
copiedFrom,
preferences: this.getPreferences(file),
},
this.getFormatOptions(file),
);
return result && this.mapPasteEditsAction(result);
}

private organizeImports(args: protocol.OrganizeImportsRequestArgs, simplifiedResult: boolean): readonly protocol.FileCodeEdits[] | readonly FileTextChanges[] {
Debug.assert(args.scope.type === "file");
const { file, project } = this.getFileAndProject(args.scope.args);
Expand Down Expand Up @@ -2928,6 +2948,10 @@ export class Session<TMessage = string> implements EventSender {
return { fixName, description, changes: this.mapTextChangesToCodeEdits(changes), commands, fixId, fixAllDescription };
}

private mapPasteEditsAction({ edits, fixId }: PasteEdits): protocol.PasteEditsAction {
return { edits: this.mapTextChangesToCodeEdits(edits), fixId };
}

private mapTextChangesToCodeEdits(textChanges: readonly FileTextChanges[]): protocol.FileCodeEdits[] {
return textChanges.map(change => this.mapTextChangeToCodeEdit(change));
}
Expand Down Expand Up @@ -3521,6 +3545,9 @@ export class Session<TMessage = string> implements EventSender {
[protocol.CommandTypes.GetMoveToRefactoringFileSuggestions]: (request: protocol.GetMoveToRefactoringFileSuggestionsRequest) => {
return this.requiredResponse(this.getMoveToRefactoringFileSuggestions(request.arguments));
},
[protocol.CommandTypes.GetPasteEdits]: (request: protocol.GetPasteEditsRequest) => {
return this.requiredResponse(this.getPasteEdits(request.arguments));
},
[protocol.CommandTypes.GetEditsForRefactorFull]: (request: protocol.GetEditsForRefactorRequest) => {
return this.requiredResponse(this.getEditsForRefactor(request.arguments, /*simplifiedResult*/ false));
},
Expand Down
1 change: 1 addition & 0 deletions src/services/_namespaces/ts.PasteEdits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "../pasteEdits.js";
2 changes: 2 additions & 0 deletions src/services/_namespaces/ts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,5 @@ import * as textChanges from "./ts.textChanges.js";
export { textChanges };
import * as formatting from "./ts.formatting.js";
export { formatting };
import * as pasteEdits from "./ts.PasteEdits.js";
export { pasteEdits };
8 changes: 2 additions & 6 deletions src/services/codefixes/importFixes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -887,10 +887,6 @@ function getSingleExportInfoForSymbol(symbol: Symbol, symbolName: string, module
}
}

function isFutureSymbolExportInfoArray(info: readonly SymbolExportInfo[] | readonly FutureSymbolExportInfo[]): info is readonly FutureSymbolExportInfo[] {
return info[0].symbol === undefined;
}

function getImportFixes(
exportInfos: readonly SymbolExportInfo[] | readonly FutureSymbolExportInfo[],
usagePosition: number | undefined,
Expand All @@ -904,7 +900,7 @@ function getImportFixes(
fromCacheOnly?: boolean,
): { computedWithoutCacheCount: number; fixes: readonly ImportFixWithModuleSpecifier[]; } {
const checker = program.getTypeChecker();
const existingImports = importMap && !isFutureSymbolExportInfoArray(exportInfos) ? flatMap(exportInfos, importMap.getImportsForExportInfo) : emptyArray;
const existingImports = importMap ? flatMap(exportInfos, importMap.getImportsForExportInfo) : emptyArray;
const useNamespace = usagePosition !== undefined && tryUseExistingNamespaceImport(existingImports, usagePosition);
const addToExisting = tryAddToExistingImport(existingImports, isValidTypeOnlyUseSite, checker, program.getCompilerOptions());
if (addToExisting) {
Expand Down Expand Up @@ -1086,7 +1082,7 @@ function createExistingImportMap(importingFile: SourceFile, program: Program) {
}

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

Expand Down
115 changes: 115 additions & 0 deletions src/services/pasteEdits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { addRange } from "../compiler/core.js";
import {
CancellationToken,
Program,
SourceFile,
Statement,
SymbolFlags,
TextRange,
UserPreferences,
} from "../compiler/types.js";
import { getLineOfLocalPosition } from "../compiler/utilities.js";
import {
codefix,
Debug,
fileShouldUseJavaScriptRequire,
forEachChild,
formatting,
getQuotePreference,
isIdentifier,
textChanges,
} from "./_namespaces/ts.js";
import { addTargetFileImports } from "./refactors/helpers.js";
import {
addExportsInOldFile,
getExistingLocals,
getUsageInfo,
} from "./refactors/moveToFile.js";
import {
CodeFixContextBase,
FileTextChanges,
LanguageServiceHost,
PasteEdits,
} from "./types.js";

const fixId = "providePostPasteEdits";
/** @internal */
export function pasteEditsProvider(
targetFile: SourceFile,
pastedText: string[],
pasteLocations: TextRange[],
copiedFrom: { file: SourceFile; range: TextRange[]; } | undefined,
host: LanguageServiceHost,
preferences: UserPreferences,
formatContext: formatting.FormatContext,
cancellationToken: CancellationToken,
): PasteEdits {
const changes: FileTextChanges[] = textChanges.ChangeTracker.with({ host, formatContext, preferences }, changeTracker => pasteEdits(targetFile, pastedText, pasteLocations, copiedFrom, host, preferences, formatContext, cancellationToken, changeTracker));
return { edits: changes, fixId };
}

function pasteEdits(
targetFile: SourceFile,
pastedText: string[],
pasteLocations: TextRange[],
copiedFrom: { file: SourceFile; range: TextRange[]; } | undefined,
host: LanguageServiceHost,
preferences: UserPreferences,
formatContext: formatting.FormatContext,
cancellationToken: CancellationToken,
changes: textChanges.ChangeTracker,
) {
let actualPastedText: string[] | undefined;
if (pastedText.length !== pasteLocations.length) {
actualPastedText = pastedText.length === 1 ? pastedText : [pastedText.join("\n")];
}
pasteLocations.forEach((paste, i) => {
changes.replaceRangeWithText(
targetFile,
{ pos: paste.pos, end: paste.end },
actualPastedText ?
actualPastedText[0] : pastedText[i],
);
});

const statements: Statement[] = [];

let newText = targetFile.text;
for (let i = pasteLocations.length - 1; i >= 0; i--) {
const { pos, end } = pasteLocations[i];
newText = actualPastedText ? newText.slice(0, pos) + actualPastedText[0] + newText.slice(end) : newText.slice(0, pos) + pastedText[i] + newText.slice(end);
}

Debug.checkDefined(host.runWithTemporaryFileUpdate).call(host, targetFile.fileName, newText, (updatedProgram: Program, originalProgram: Program | undefined, updatedFile: SourceFile) => {
const importAdder = codefix.createImportAdder(updatedFile, updatedProgram, preferences, host);
if (copiedFrom?.range) {
Debug.assert(copiedFrom.range.length === pastedText.length);
copiedFrom.range.forEach(copy => {
addRange(statements, copiedFrom.file.statements, getLineOfLocalPosition(copiedFrom.file, copy.pos), getLineOfLocalPosition(copiedFrom.file, copy.end) + 1);
});
const usage = getUsageInfo(copiedFrom.file, statements, originalProgram!.getTypeChecker(), getExistingLocals(updatedFile, statements, originalProgram!.getTypeChecker()));
Debug.assertIsDefined(originalProgram);
const useEsModuleSyntax = !fileShouldUseJavaScriptRequire(targetFile.fileName, originalProgram, host, !!copiedFrom.file.commonJsModuleIndicator);
addExportsInOldFile(copiedFrom.file, usage.targetFileImportsFromOldFile, changes, useEsModuleSyntax);
addTargetFileImports(copiedFrom.file, usage.oldImportsNeededByTargetFile, usage.targetFileImportsFromOldFile, originalProgram.getTypeChecker(), updatedProgram, importAdder);
}
else {
const context: CodeFixContextBase = {
sourceFile: updatedFile,
program: originalProgram!,
cancellationToken,
host,
preferences,
formatContext,
};
forEachChild(updatedFile, function cb(node) {
if (isIdentifier(node) && !originalProgram?.getTypeChecker().resolveName(node.text, node, SymbolFlags.All, /*excludeGlobals*/ false)) {
// generate imports
importAdder.addImportForUnresolvedIdentifier(context, node, /*useAutoImportProvider*/ true);
}
node.forEachChild(cb);
});
}
importAdder.writeFixes(changes, getQuotePreference(copiedFrom ? copiedFrom.file : targetFile, preferences));
});
}
6 changes: 4 additions & 2 deletions src/services/refactors/moveToFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,8 @@ export function deleteUnusedOldImports(oldFile: SourceFile, toMove: readonly Sta
}
}

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

function getExistingLocals(sourceFile: SourceFile, statements: readonly Statement[], checker: TypeChecker) {
/** @internal */
export function getExistingLocals(sourceFile: SourceFile, statements: readonly Statement[], checker: TypeChecker) {
const existingLocals = new Set<Symbol>();
for (const moduleSpecifier of sourceFile.imports) {
const declaration = importFromModuleSpecifier(moduleSpecifier);
Expand Down
Loading

0 comments on commit be8fb98

Please sign in to comment.