Skip to content

Code fix to remove unused named import should preserve default import #17078

Merged
minestarks merged 10 commits into
microsoft:masterfrom
minestarks:removeimportfix
Jul 13, 2017
Merged

Code fix to remove unused named import should preserve default import #17078
minestarks merged 10 commits into
microsoft:masterfrom
minestarks:removeimportfix

Conversation

@minestarks

Copy link
Copy Markdown
Member

Fixes #14164

@minestarks minestarks requested review from amcasey and aozgaa July 10, 2017 21:24
@minestarks minestarks changed the title Code fix to remove unused import should preserve default import Code fix to remove unused named import should preserve default import Jul 10, 2017

@amcasey amcasey left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mostly open questions - no actual requests.

Comment thread src/harness/fourslash.ts Outdated
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(", ") : "" }`);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"found:"?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 :)

Comment thread src/harness/fourslash.ts Outdated
}

if (this.currentCaretPosition < 0) {
this.currentCaretPosition = 0;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this okay? Shouldn't it be an error to make the position go negative?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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' />

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's "13FS"?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@minestarks minestarks Jul 10, 2017

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 })];

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if there are comments in and around the names?

@minestarks minestarks Jul 10, 2017

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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'

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I agree with the last one. The others look good.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great, agreed on the desired behavior - I just wasn't sure how to achieve it. Thanks.

@aozgaa aozgaa left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a couple points of confusion with the moved code, comment-range handling, nits, and a request to add a test.

Comment thread src/harness/fourslash.ts Outdated
}

if (this.currentCaretPosition < 0) {
this.currentCaretPosition = 0;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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'"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you change this to use the | delimiters like the other comments in this file?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 })];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like the first check was removed. Why?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, according to types.ts, it is a redundant check

Comment thread src/harness/fourslash.ts Outdated
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(", ") : "" }`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is great!

maybe ...but actually got ${actions ? actions.length : 0} actions, namely\n${...?

Also, would splitting with a newline be more readable?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, since I got two comments on this, I'll rearrange this into a better string

@amcasey amcasey left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for making the extra fixes!

Comment thread src/harness/fourslash.ts
}
else {
// The span being replaced includes the caret position, place the caret at the beginning of the span
this.currentCaretPosition = offsetStart;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Comment thread src/harness/fourslash.ts Outdated
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}"`) : "" }`);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a helper for retrieving the platform-specific newline? (i.e. does this work on Linux?)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah right.


default:
return [deleteDefault()];
return deleteDefault();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for fixing this!


// @noUnusedLocals: true
// @Filename: file2.ts
//// [| import /* 1 */ A /* 2 */, /* 3 */ { x } from './a'; |]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

{ /* 4 */ x /* 5 */ } /* 6 */?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@minestarks minestarks Jul 11, 2017

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

@minestarks

Copy link
Copy Markdown
Member Author

@aozgaa do I have your approval?

@aozgaa aozgaa left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, thanks for the clarifications and changes!


case SyntaxKind.NamespaceImport:
const namespaceImport = <NamespaceImport>parent;
if (namespaceImport.name === identifier && !(<ImportClause>namespaceImport.parent).name) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, according to types.ts, it is a redundant check

@minestarks minestarks merged commit fd2dd2e into microsoft:master Jul 13, 2017
@microsoft microsoft locked and limited conversation to collaborators Jun 14, 2018
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants