-
Notifications
You must be signed in to change notification settings - Fork 12.8k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
'Move to file' refactor #53542
'Move to file' refactor #53542
Conversation
Thanks for the PR! It looks like you've changed the TSServer protocol in some way. Please ensure that any changes here don't break consumers of the current TSServer API. For some extra review, we'll ping @sheetalkamat, @mjbvz, @zkat, and @joj for you. Feel free to loop in other consumers/maintainers if necessary |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The protocol parts of this change look good to me. Excited to start testing this in VS Code
src/server/protocol.ts
Outdated
/* The 'name' property from the refactoring that offered this action */ | ||
refactor: string; | ||
/* The 'name' property from the refactoring action */ | ||
action: string; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Are the refactor
and action
properties needed? Or can we assume they are Move to file
since we're making this call
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, I believe the refactor
and the action
properties are needed to compute the associated code action. getEditsForAction
in types.ts matches the action
property to the correct refactor action.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For this new API though, won't the action always be Move to file
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I said today that my API questions didn’t affect the protocol, but I was kind of wrong; there is a parallel. My suggestion on the LS side was to remove the move-to-file-specific LS method and allow an extra argument to be passed to the existing getEditsForRefactor
method. The exact same possibility exists here: instead of making a new command, we could reuse the existing GetEditsForRefactor
command if we modify its request type to allow an argument containing the targetFile
property. @mjbvz thoughts?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm good with that. For the protocol, I'll just need the expected argument's type
Testing this out in VS Code, a few small issues I noticed so far:
|
@navya9singh microsoft/vscode#178535 Hooks up initial support for this refactoring on the VS Code side. I'm hoping to land it in the first VS code 1.78 insiders that rolls out later this week. Should make testing easier. Please also send me any feedback on the UI |
Add initial support for move to file Fixes #176705 For microsoft/TypeScript#53542
It looks like quite a bit of code is copied and pasted from |
@andrewbranch Can the duplicated functions be places in a file under |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Mostly clarity/style nits, but one potential bug found. The only big issue outstanding is still the API. As I mentioned in chat:
We probably shouldn't be returning that in the GetApplicableRefactors command by default, because clients will need to add quite a bit of functionality in order to handle it, but we're lumping it in with every other refactor that doesn't require any special handling. I think this will break every non-VS Code client if we don't start the fork in the API flow earlier, at GetApplicableRefactors.
Let’s see if the offline discussion about that goes anywhere.
src/services/refactors/moveToFile.ts
Outdated
let useEsModuleSyntax = true; | ||
if (newFileExists) { | ||
const newFilePath = program.getSourceFile(newFile)?.path; | ||
if (extensionFromPath(newFile) === Extension.Js && newFilePath && getImpliedNodeFormatForFile(newFilePath, /*packageJsonInfoCache*/ undefined, host, program.getCompilerOptions()) !== ModuleKind.ESNext && (oldFile.commonJsModuleIndicator || program.getCompilerOptions().verbatimModuleSyntax)) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bugs and clarity nits:
Js
isn’t the only JS extension—useisSourceFileJS(newSourceFile)
orhasJsFileExtension(newFile)
to cover cjs, mjs, jsx, etc.- Use
newSourceFile.impliedNodeFormat
instead ofgetImpliedNodeFormatForFile
(the latter gets called once during program construction and gets cached on the former) - Since this is the case where the new file already exists, it seems like
newSourceFile.commonJsModuleIndicator
andexternalModuleIndicator
are relevant here. - Whatever the logic is, it has to be commented since it’s extremely hard to think about.
- What about the case where the new file doesn’t exist? In that case, we can’t look at the new file’s existing syntax, but the other considerations still apply. There may need to be a test or two for that.
// Cannot use require if not JS
if (isSourceFileJs(newSourceFile) && (
// Use require if detected CJS format
newSourceFile.impliedNodeFormat === ModuleKind.CommonJS ||
!newSourceFile.impliedNodeFormat && (
// Target file is already using require,
// or not using import and old file is using require
newSourceFile.commonJsModuleIndicator ||
!newSourceFile.externalModuleIndicator && oldSourceFile.commonJsModuleIndicator) ||
// Must use require in CJS emit with verbatimModuleSyntax
getEmitModuleKind(compilerOptions) === ModuleKind.CommonJS && compilerOptions.verbatimModuleSyntax
)) {
useEsModuleSyntax = false;
}
src/services/refactors/moveToFile.ts
Outdated
const body = addExports(oldFile, toMove.all, usage.oldFileImportsFromNewFile, useEsModuleSyntax); | ||
if (newFileExists) { | ||
const sourceFile = program.getSourceFile(newFile); | ||
if (sourceFile && sourceFile.statements.length > 0) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Style nit:
If sourceFile
is undefined
, something has gone horribly wrong since you were told that newFileExists
and you shouldn’t continue. Wrap program.getSourceFile(newFile)
in a Debug.checkDefined
or something. And since you access it in an earlier if
block, just do that at the very top of the function. (getSourceFile
is pretty cheap but not free, and it’s nice to avoid repeating yourself.)
const targetSourceFile = newFileExists ? Debug.checkDefined(program.getSourceFile(newFile)) : undefined;
Then, you never have to check newFileExists
again; you can just check if (targetSourceFile)
and know it means the same thing, while narrowing undefined
out of its type.
src/services/refactors/moveToFile.ts
Outdated
]; | ||
} | ||
|
||
function getNewFileImportsAndAddExportInOldFile( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Clarity nit:
Comments in this function would be helpful:
- Why is
importAdder
sometimes undefined? - Why might it throw when using it, and what are we doing to recover?
src/services/refactors/moveToFile.ts
Outdated
if (fileName) { | ||
const sourceFile = program.getSourceFile(newFile); | ||
if (sourceFile) { | ||
const pathToNewFileWithExtension = resolvePath(getDirectoryPath(oldFile.path), fileName); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I’m pretty sure pathToNewFileWithExtension === fileName
100% of the time. Are you sure this is necessary? getModuleSpecifier
just wants the file name of the file you’re importing, which you already have with fileName
.
src/services/refactors/moveToFile.ts
Outdated
const fileName = resolved?.resolvedModule?.resolvedFileName; | ||
if (fileName) { | ||
const sourceFile = program.getSourceFile(newFile); | ||
if (sourceFile) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Style nit:
Same as before, if sourceFile
is undefined
, something has gone horribly wrong and you can’t do anything useful to recover, so there’s no need to code defensively here. (Also, hoist this program.getSourceFile
out so you’re not retrieving it repeatedly in this nested loop.)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've moved program.getSourceFile
out but the sourceFile
will be undefined if we are moving to a new file. So for recomputing the module specifier, if
- We move to an existing file, import adder will handle it
- If moving to a blank existing file, we will recompute it using
getModuleSpecifier()
- If moving to a new file, it will get copied over as is because to use
getModuleSpecifier()
we need thetargetSourceFile
which is not available
src/services/refactors/moveToFile.ts
Outdated
} | ||
|
||
function getNewStatementsAndRemoveFromOldFile( | ||
oldFile: SourceFile, newFile: string, usage: UsageInfo, changes: textChanges.ChangeTracker, toMove: ToMove, program: Program, host: LanguageServiceHost, newFilename: string, preferences: UserPreferences, newFileExists: boolean, importAdder?: codefix.ImportAdder |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Clarity nits:
- Throughout this file, can we do a review of everywhere the word “new” is used and make sure it’s only getting used when the file is actually new? I propose to settle on the term “target file” to mean the file to which we’re moving code, whether it is new or existing. Everything here seems to be using the terminology “new” as a legacy from when this code was only used for a refactor called “Move to new file.”
- This function seems to have a duplicated parameter,
newFile
andnewFilename
. - You could simplify the signature even more by removing
newFileExists
and making the type oftargetFile
bestring | SourceFile
. If it’s a string, it’s the file name for a new file. If it’s a SourceFile, it’s the existing target file.
src/services/services.ts
Outdated
function getMoveToRefactoringFileSuggestions(fileName: string, positionOrRange: number | TextRange, preferences: UserPreferences = emptyOptions): { newFileName: string | undefined, files: string[] | undefined } { | ||
synchronizeHostData(); | ||
const sourceFile = getValidSourceFile(fileName); | ||
const program = getProgram(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
program
is already available to you without declaring this constant; see the other functions in this file as an example. This is similar to other places where I mentioned that code was unnecessarily defensive—if you find yourself with a type that says it could be undefined
, but your function can’t do anything useful if that object is undefined
, it’s worth figuring out whether it can actually be undefined in reality. Often, the answer is that if you have an undefined
, some core invariant has already been violated, and it’s better to throw than silently do nothing. Presenting the user with an empty list of files isn’t going to help the user—they’re better off seeing VS Code present them an error message saying that something went wrong. And when that user files a bug, you’ll be very happy that they were able to paste a stack trace to exactly where something went wrong instead of just saying “I tried to move this code to another file and nothing happened 🤷”
src/services/services.ts
Outdated
const allFiles = program?.getSourceFiles().filter(sourceFile => !program?.isSourceFileFromExternalLibrary(sourceFile)).map(f => f.fileName); | ||
const extension = extensionFromPath(fileName); | ||
const files: string[] = []; | ||
if (allFiles) { | ||
for (const file of allFiles) { | ||
const fileExtension = extensionFromPath(file); | ||
if (sourceFile === getValidSourceFile(file) || extension === Extension.Ts && fileExtension === Extension.Dts || extension === Extension.Dts && startsWith(getBaseFileName(file), "lib.") && fileExtension === Extension.Dts) { | ||
continue; | ||
} | ||
if (extension === fileExtension) { | ||
files.push(file); | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This code iterates over the source files 3 times and creates and mutates a lot of intermediate arrays. It looks the filter
, map
, and for
loop can be replaced with a single mapDefined
call.
src/services/services.ts
Outdated
} | ||
} | ||
} | ||
//creating new filename |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
//creating new filename |
src/services/services.ts
Outdated
let newFileName; | ||
if (program) { | ||
newFileName = createNewFileName(sourceFile, program, getRefactorContext(sourceFile, positionOrRange, preferences, emptyOptions), host); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Don’t forget to simplify this after program
is no longer typed as possibly undefined
.
src/services/types.ts
Outdated
@@ -631,7 +631,9 @@ export interface LanguageService { | |||
applyCodeActionCommand(fileName: string, action: CodeActionCommand | CodeActionCommand[]): Promise<ApplyCodeActionCommandResult | ApplyCodeActionCommandResult[]>; | |||
|
|||
getApplicableRefactors(fileName: string, positionOrRange: number | TextRange, preferences: UserPreferences | undefined, triggerReason?: RefactorTriggerReason, kind?: string): ApplicableRefactorInfo[]; | |||
getMoveToRefactoringFileSuggestions(fileName: string, positionOrRange: number | TextRange, preferences: UserPreferences | undefined, triggerReason?: RefactorTriggerReason, kind?: string): { newFileName: string | undefined, files: string[] | undefined }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
After fixing services.ts, can these return type properties ever actually be undefined
?
#53915 can be incorporated now with something like this process:
|
} | ||
}); | ||
baselineTsserverLogs("refactors", "handles moving statement to an existing file", session); | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Instead of deleting this test, you can modify it to use the TS Server command you expect VS Code to use now. (In doing so, you’ll find that you need another update to protocol.ts to allow the interactive refactor arguments.)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice work! This was a long process; thanks for your patience over the many revisions. This will be an awesome feature 🙂
src/server/session.ts
Outdated
const { newFileName, files } = project.getLanguageService().getMoveToRefactoringFileSuggestions(file, this.extractPositionOrRange(args, scriptInfo), this.getPreferences(file)); | ||
return { newFileName, files }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
const { newFileName, files } = project.getLanguageService().getMoveToRefactoringFileSuggestions(file, this.extractPositionOrRange(args, scriptInfo), this.getPreferences(file)); | |
return { newFileName, files }; | |
return project.getLanguageService().getMoveToRefactoringFileSuggestions(file, this.extractPositionOrRange(args, scriptInfo), this.getPreferences(file)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Apart from one nit it looks good to go
Thank you so much for working on this ❤️ |
Very nice feature, thanks for your work!! But this doesn't seem to behave correctly for overloads Given a function like function add(x: number, y: number): number;
function add(x: string, y: string): string;
function add(x: any, y: any) {
return x + y;
} if you right click any of them and use this refactor, it will only move that part Code_-_Insiders_pnskO6qwK9.mp4Edit: I discovered it works correctly if you highlight all the functions and then use the refactor on the selection. But it would probably be better and more intuitive to properly handle overloads even if you don't? It works correctly for jsdoc at least, so I would expect it to also work for overloads The old "Move to a new file" also seems affected by this |
Folks, please create separate issues instead of leaving them on a closed PR. Thanks! |
Fixes #29988