Code fix to remove unused named import should preserve default import #17078
Conversation
amcasey
left a comment
There was a problem hiding this comment.
Mostly open questions - no actual requests.
| if (index === undefined) { | ||
| if (!(actions && actions.length === 1)) { | ||
| this.raiseError(`Should find exactly one codefix, but ${actions ? actions.length : "none"} found.`); | ||
| this.raiseError(`Should find exactly one codefix, but ${actions ? actions.length : "none"} found. ${actions ? actions.map(a => `"${a.description}"`).join(", ") : "" }`); |
There was a problem hiding this comment.
Yeah, I had it that way at first, but it looks silly when actions.length is zero. Instead of adding fancy formatting logic I went with something that looks sort of okay in either case :)
| } | ||
|
|
||
| if (this.currentCaretPosition < 0) { | ||
| this.currentCaretPosition = 0; |
There was a problem hiding this comment.
Why is this okay? Shouldn't it be an error to make the position go negative?
There was a problem hiding this comment.
It's an effect of this code to adjust caret position as we apply edits:
if (offsetStart <= this.currentCaretPosition) {
this.currentCaretPosition += editDelta;
}editDelta can be negative. If you delete a span that contains the current caret position (perfectly valid), the caret position may end up going negative.
There are alternative ways of fixing this (e.g. make sure the caret position remains positive inside the for loop instead of fixing it afterwards) but I didn't see a compelling reason to prefer one over the other.
There was a problem hiding this comment.
If the caret is in the middle of some range that is deleted, should the caret go to the beginning of the range? Or be invalidated?
I'm unsure this can happen in a real editing scenario, but it can make sense for sequencing checks in tests.
There was a problem hiding this comment.
If the caret is in the middle of some range that is deleted, should the caret go to the beginning of the range?
@aozgaa yes. This was also the conclusion we arrived with @amcasey after discussing offline. Will make that change.
Perfectly valid scenario - this happens in remove unused quickfix, for instance (which is I guess why I was hitting the invalid caret situation)
| @@ -0,0 +1,12 @@ | |||
| /// <reference path='fourslash.ts' /> | |||
There was a problem hiding this comment.
It's the 13th in a series of files that have that exact naming convention.... I think FS stands for fourslash.
| const startPosition = textChanges.getAdjustedStartPosition(sourceFile, previousToken, {}, textChanges.Position.FullStart); | ||
| return [deleteRange({ pos: startPosition, end: namedBindings.end })]; | ||
| } | ||
| return undefined; |
There was a problem hiding this comment.
The caller declares that it can return undefined, but it's not clear that that ever happened or that it would work if it did.
There also appear to be cases where it returns [undefined], which seems even weirder.
There was a problem hiding this comment.
I had my questions about this too.
My understanding is that ultimately this undefined will be returned from getCodeActions which is treated by the codeFixProvider to mean no code fixes are available. So I'm pretty confident returning undefined is valid and will be handled gracefully.
However I am confused about how to handle seemingly impossible cases (like this one). I don't expect to ever reach this branch and I don't have any test coverage for it. I returned undefined here to avoid deleting code in an unexpected case. I considered throwing too. But the previous version of the code would have returned return [deleteRange(namespaceImport)]; and I'm not sure why. Asking @aozgaa ...
I'm not sure about [undefined] -- I have a feeling VS would not take that too well.
There was a problem hiding this comment.
I wasn't aware we would return [undefined] in any cases before. Regardless of whether or not the VS extension throws an exception when that happens today (I'd be mildly surprised if it didn't), we definitely should not return an arrays with null values.
We should standardize and agree to only return undefined, even though ts.codefix.getFixes doesn't care.
| const previousToken = getTokenAtPosition(sourceFile, namedBindings.pos - 1, /*includeJsDocComment*/ false); | ||
| if (previousToken && previousToken.kind === SyntaxKind.CommaToken) { | ||
| const startPosition = textChanges.getAdjustedStartPosition(sourceFile, previousToken, {}, textChanges.Position.FullStart); | ||
| return [deleteRange({ pos: startPosition, end: namedBindings.end })]; |
There was a problem hiding this comment.
What happens if there are comments in and around the names?
There was a problem hiding this comment.
import /* foo */ A, { x } from './a' => import /* foo */ A from './a'
import A, { /* foo */ x } from './a' => import A from './a'
import A, /* foo */ { x } from './a' => import A from './a'
import A /* foo */, { x } from './a' => import A from './a'
There was a problem hiding this comment.
I'm not sure I agree with the last one. The others look good.
There was a problem hiding this comment.
Using Position.Start instead of Position.FullStart on the comma token should change the behavior to agree with @amcasey's thoughts on the last case while preserving the other cases.
Please add a fourslash test documenting this behavior.
There was a problem hiding this comment.
Great, agreed on the desired behavior - I just wasn't sure how to achieve it. Thanks.
aozgaa
left a comment
There was a problem hiding this comment.
a couple points of confusion with the moved code, comment-range handling, nits, and a request to add a test.
| } | ||
|
|
||
| if (this.currentCaretPosition < 0) { | ||
| this.currentCaretPosition = 0; |
There was a problem hiding this comment.
If the caret is in the middle of some range that is deleted, should the caret go to the beginning of the range? Or be invalidated?
I'm unsure this can happen in a real editing scenario, but it can make sense for sequencing checks in tests.
| @@ -100,7 +98,7 @@ namespace ts.codefix { | |||
| // or "'import {a, b as ns} from './file'" | |||
There was a problem hiding this comment.
Can you change this to use the | delimiters like the other comments in this file?
There was a problem hiding this comment.
actually this comment is wrong. I'm removing it. The correct cases are already articulated in inline comments just below.
| // import d|, { a }| from './file' | ||
| const previousToken = getTokenAtPosition(sourceFile, namedBindings.pos - 1, /*includeJsDocComment*/ false); | ||
| if (previousToken && previousToken.kind === SyntaxKind.CommaToken) { | ||
| const startPosition = textChanges.getAdjustedStartPosition(sourceFile, previousToken, {}, textChanges.Position.FullStart); |
There was a problem hiding this comment.
this doesn't appear to be called anywhere outside of textChanges.ts. Can you explain why it is used here/what happens if it we just use previousToken.getStart(sourceFile)?
There was a problem hiding this comment.
Good question. It's just carried over from the method I was refactoring, so I'm not sure why it was used specifically. I'll investigate.
There was a problem hiding this comment.
OK actually previousToken.getStart() is the one to use here, to avoid the comment problem called out below. Using fullStart would wipe out comments leading up to the comma.
| const previousToken = getTokenAtPosition(sourceFile, namedBindings.pos - 1, /*includeJsDocComment*/ false); | ||
| if (previousToken && previousToken.kind === SyntaxKind.CommaToken) { | ||
| const startPosition = textChanges.getAdjustedStartPosition(sourceFile, previousToken, {}, textChanges.Position.FullStart); | ||
| return [deleteRange({ pos: startPosition, end: namedBindings.end })]; |
There was a problem hiding this comment.
Using Position.Start instead of Position.FullStart on the comma token should change the behavior to agree with @amcasey's thoughts on the last case while preserving the other cases.
Please add a fourslash test documenting this behavior.
| const startPosition = textChanges.getAdjustedStartPosition(sourceFile, previousToken, {}, textChanges.Position.FullStart); | ||
| return [deleteRange({ pos: startPosition, end: namedBindings.end })]; | ||
| } | ||
| return undefined; |
There was a problem hiding this comment.
I wasn't aware we would return [undefined] in any cases before. Regardless of whether or not the VS extension throws an exception when that happens today (I'd be mildly surprised if it didn't), we definitely should not return an arrays with null values.
We should standardize and agree to only return undefined, even though ts.codefix.getFixes doesn't care.
|
|
||
| case SyntaxKind.NamespaceImport: | ||
| const namespaceImport = <NamespaceImport>parent; | ||
| if (namespaceImport.name === identifier && !(<ImportClause>namespaceImport.parent).name) { |
There was a problem hiding this comment.
It looks like the first check was removed. Why?
There was a problem hiding this comment.
I believed namespaceImport.name !== identifier is an unreachable condition. So it seemed like an arbitrary extra check. If we have an identifier whose parent is a NamespaceImport, then by definition namespaceImport.name has to be that identifier. Is that not right?
There was a problem hiding this comment.
Yes, according to types.ts, it is a redundant check
| if (index === undefined) { | ||
| if (!(actions && actions.length === 1)) { | ||
| this.raiseError(`Should find exactly one codefix, but ${actions ? actions.length : "none"} found.`); | ||
| this.raiseError(`Should find exactly one codefix, but ${actions ? actions.length : "none"} found. ${actions ? actions.map(a => `"${a.description}"`).join(", ") : "" }`); |
There was a problem hiding this comment.
This is great!
maybe ...but actually got ${actions ? actions.length : 0} actions, namely\n${...?
Also, would splitting with a newline be more readable?
There was a problem hiding this comment.
Okay, since I got two comments on this, I'll rearrange this into a better string
amcasey
left a comment
There was a problem hiding this comment.
Thanks for making the extra fixes!
| } | ||
| else { | ||
| // The span being replaced includes the caret position, place the caret at the beginning of the span | ||
| this.currentCaretPosition = offsetStart; |
There was a problem hiding this comment.
Would it make sense to put the caret at the end of the new text? It should have the same behavior as this code when the new text is empty.
There was a problem hiding this comment.
In practice, the test cases only hit this when the caret is already at the start of the span (i.e. this.currentCaretPosition === offsetStart already). So it makes sense not to move it in that case. When the caret is in the middle, it's kind of arbitrary which way to go. I chose the route with the least logic. (Note: I checked VS it does something different altogether. When the caret is in the middle, it only repositions the caret if it would end up outside the range of the new text. Which makes sense. But we literally never hit this case in tests, so I'm not going to spend any more time on it).
| if (index === undefined) { | ||
| if (!(actions && actions.length === 1)) { | ||
| this.raiseError(`Should find exactly one codefix, but ${actions ? actions.length : "none"} found. ${actions ? actions.map(a => `"${a.description}"`).join(", ") : "" }`); | ||
| this.raiseError(`Should find exactly one codefix, but ${actions ? actions.length : "none"} found. ${actions ? actions.map(a => `\r\n "${a.description}"`) : "" }`); |
There was a problem hiding this comment.
Is there a helper for retrieving the platform-specific newline? (i.e. does this work on Linux?)
|
|
||
| default: | ||
| return [deleteDefault()]; | ||
| return deleteDefault(); |
|
|
||
| // @noUnusedLocals: true | ||
| // @Filename: file2.ts | ||
| //// [| import /* 1 */ A /* 2 */, /* 3 */ { x } from './a'; |] |
There was a problem hiding this comment.
Ha, I guess I got tired of typing out comments. I didn't think it was interesting to test since we're removing the { x } node altogether. But might as well.
There was a problem hiding this comment.
The other thing is, I can't decide if that /* 6 */ should stay or go. I think either way is fine, so I don't want to necessarily codify the existing behavior in tests. (Currently, /* 6 */ stays.)
There was a problem hiding this comment.
My preference, in such cases, is to capture the current behavior but add a comment in the test (which will fail if it changes) explaining that the test is descriptive, rather than normative (i.e. the failure can be addressed by updating the baseline).
|
@aozgaa do I have your approval? |
aozgaa
left a comment
There was a problem hiding this comment.
Looks good, thanks for the clarifications and changes!
|
|
||
| case SyntaxKind.NamespaceImport: | ||
| const namespaceImport = <NamespaceImport>parent; | ||
| if (namespaceImport.name === identifier && !(<ImportClause>namespaceImport.parent).name) { |
There was a problem hiding this comment.
Yes, according to types.ts, it is a redundant check
Fixes #14164