Skip to content
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

Extract method #16960

Closed
wants to merge 60 commits into from
Closed

Conversation

RyanCavanaugh
Copy link
Member

@RyanCavanaugh RyanCavanaugh commented Jul 6, 2017

Implements the "extract function" refactor

Given a user selection, this produces a set of "extractions" the user can select from. For example, with an expression in a class, we can refactor into a peer method, a function in an enclosing namespace, or a function in the same file (these are referred to as "scopes" in the code):

refactor2

Not all selections will produce the same set of extractions. For example, if we refer to a property of this in the selection body, then we won't be able to extract to the enclosing namespace or file:

image

Still writing some tests so expect a few commits with bugfixes to come in, but it's pushing 2,000 lines of implementation already so jump in

Most code courtesy @vladima so I may not be able to perfectly answer all questions

@ghost
Copy link

ghost commented Jul 6, 2017

Tests should be fixed by ivogabe/gulp-typescript#522

@RyanCavanaugh
Copy link
Member Author

🔔

Copy link
Member

@weswigham weswigham left a comment

Choose a reason for hiding this comment

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

Not all selections will produce the same set of extractions. For example, if we refer to a property of this in the selection body, then we won't be able to extract to the enclosing namespace or file

You could add an option to extract to namespace where the this object becomes a normal function parameter, rather than this, provided no private or protected members are in use in the extracted code. I've done this by hand frequently in the past (extracting internal code into an external helper) - it may be worth adding. Actually, even private member access can be transformed to extracted-function-parameters provided they don't have unexposed types. Maybe an enhancement for the future.

resolveNameAtLocation(location: Node, name: string, meaning: SymbolFlags): Symbol | undefined {
location = getParseTreeNode(location);
return resolveName(location, name, meaning, /*nameNotFoundMessage*/ undefined, name);
resolveName(name, location, meaning) {
Copy link
Member

Choose a reason for hiding this comment

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

Is this the same as resolveNameAtLocation, but with a changed argument order and no looking up of parse tree nodes (I can only assume so it works on synthesized nodes with parent pointers setup)? Can we not just remove the looking up of parse tree nodes in resolveNameAtLocation if that's the case? Oh, nameArg is also undefined. Can that not just be added as an optional parameter to this existing function?

Copy link
Contributor

Choose a reason for hiding this comment

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

@rbuckton would know authoritatively, but as I understand it, we check isParseTreeNode on public API requests because the checker does not work well with synthesized nodes, since we don't perform binding on synthesized nodes as part of transforms (for performance reasons?) and we may inspect the literal text as part of some checker operations. That is, the checker API is only supposed to work with trees parsed and bound from an actual source file.

Copy link
Member

Choose a reason for hiding this comment

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

@aozgaa is correct. If the checker receives a node that did not originate from a parse tree, we attempt to find the parse tree node from which it was created. Passing in a synthesized node to the checker without a parse-tree check will likely result in compiler crashes.

Copy link
Member

Choose a reason for hiding this comment

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

That said, resolveNameAtLocation (nee. resolveName) is marked /* @internal */ and isn't used in transforms. The guard is mostly present in case we decide to use it at some point down the road.

@@ -706,7 +706,6 @@ namespace ts.formatting {
if (isToken(child) && child.kind !== SyntaxKind.JsxText) {
// if child node is a token, it does not impact indentation, proceed it using parent indentation scope rules
const tokenInfo = formattingScanner.readTokenInfo(child);
Debug.assert(tokenInfo.token.end === child.end, "Token end is child end");
Copy link
Member

Choose a reason for hiding this comment

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

Was this assertion incorrect? This PR didn't change any parsing related things, AFAIK?

Copy link
Member Author

Choose a reason for hiding this comment

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

Finally tracked this down - the problem is we were creating a synthetic type reference node with text like { x: string } when the type was anonymous, which was confusing the scanner because it expects a type reference node to point to an identifier or qualified name, so the formatting scanner and syntax tree disagreed about whether to expect a token. Fixed in extractMethod#485 and restored the assert

const nullTransformationContext: TransformationContext = {
enableEmitNotification: noop,
enableSubstitution: noop,
endLexicalEnvironment: () => undefined,
Copy link
Member

Choose a reason for hiding this comment

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

Isn't this just noop?

Copy link
Member Author

Choose a reason for hiding this comment

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

Sort of. noop returns void, but endLexicalEnvironment expects a Statement[] | undefined return value. Switched to noop and added a type assertion

case SyntaxKind.FunctionDeclaration:
return `function ${s.name.getText()}`;
case SyntaxKind.ArrowFunction:
return "arrow function";
Copy link
Member

Choose a reason for hiding this comment

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

These won't be localized.

Copy link
Member

Choose a reason for hiding this comment

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

I think Roslyn decided not to localize syntax kind names, but their use case is slightly different.

Copy link
Member Author

Choose a reason for hiding this comment

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

Half these are actual keywords. Do we actually expect the loc team to change e.g. "method" to correct corresponding word in the target language?

@rosieks
Copy link

rosieks commented Jul 15, 2017

Does it work with JSX? Can I select some JSX markup and extract it to stateless component?

@@ -2645,9 +2645,8 @@ namespace ts {
* Does not include properties of primitive types.
*/
/* @internal */ getAllPossiblePropertiesOfType(type: Type): Symbol[];

/* @internal */ resolveName(name: string, location: Node, meaning: SymbolFlags): Symbol;
Copy link
Member

Choose a reason for hiding this comment

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

What happened to | undefined? Does it throw on failure?

Copy link
Member Author

Choose a reason for hiding this comment

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

The compiler doesn't have strictNullChecks on. Added | undefined to be explicit.

export type ChangeNodeOptions = ConfigurableStartEnd & InsertNodeOptions;

interface Change {
readonly sourceFile: SourceFile;
readonly range: TextRange;
readonly useIndentationFromFile?: boolean;
readonly node?: Node;
readonly options?: ChangeNodeOptions;
readonly node?: Node | Node[];
Copy link
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 understand the type. It's either undefined, one Node, zero Nodes, a singleton list of Nodes, or multiple Nodes?

Copy link
Member Author

Choose a reason for hiding this comment

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

Changed this to be more explicit, but we actually do allocate a fair number of these and I think it's worth avoiding the array allocation in the (much more common) 1-element case.

@@ -161,6 +165,10 @@ namespace ts.textChanges {
return new ChangeTracker(context.newLineCharacter === "\n" ? NewLineKind.LineFeed : NewLineKind.CarriageReturnLineFeed, context.rulesProvider);
}

public static fromRefactorContext(context: RefactorContext) {
return new ChangeTracker(context.newLineCharacter === "\n" ? NewLineKind.LineFeed : NewLineKind.CarriageReturnLineFeed, context.rulesProvider);
Copy link
Member

Choose a reason for hiding this comment

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

This appears to be the third occurrence. Consider extracting context.newLineKind.

Copy link
Member Author

Choose a reason for hiding this comment

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

👍 but only found two, is the third in another file?

Copy link
Member

Choose a reason for hiding this comment

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

Yep, separate file.

@@ -241,6 +249,13 @@ namespace ts.textChanges {
return this;
}

public replaceNodes(sourceFile: SourceFile, oldNodes: Node | Node[], newNodes: Node | Node[], options: ChangeNodeOptions & ChangeNodesOptions = {}) {
const startPosition = getAdjustedStartPosition(sourceFile, isArray(oldNodes) ? oldNodes[0] : oldNodes, options, Position.Start);
const endPosition = getAdjustedEndPosition(sourceFile, isArray(oldNodes) ? lastOrUndefined(oldNodes) : oldNodes, options);
Copy link
Member

Choose a reason for hiding this comment

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

From context, I assume that lastOrUndefined never returns undefined?

Copy link
Member Author

Choose a reason for hiding this comment

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

It's like the .NET version (actual signature is export function lastOrUndefined<T>(array: T[]): T | undefined {)

Copy link
Member

Choose a reason for hiding this comment

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

lastOrUndefined(ar) returns undefined if either the ar is undefined or ar is empty. We also have a firstOrUndefined(ar) that does the same thing except it reads the first element.

Generally, lastOrUndefined is just a shortcut for ar[ar.length - 1].

Copy link
Member

Choose a reason for hiding this comment

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

To clarify, this particular call to lastOrUndefined never returns undefined? If that's not the case, is getAdjustedEndPosition going to throw?

}
// strip initial indentation (spaces or tabs) if text will be inserted in the middle of the line
// however keep indentation if it is was forced
text = posStartsLine || change.options.indentation !== undefined ? text : text.replace(/^\s+/, "");
Copy link
Member

Choose a reason for hiding this comment

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

This seems like an expensive way to TrimStart. There isn't a helper?

Copy link
Member

Choose a reason for hiding this comment

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

Also, I'm routinely confused by JS's short-circuiting operators, but won't text be a number if posStartsLine is defined?

Copy link
Member Author

Choose a reason for hiding this comment

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

We don't have a leading trim function yet; since we'd theoretically have to scan for all the Unicode whitespace I'm not sure it can be done more cheaply than that RegEx.

Added parens for clarity (the || expression is the first operand to ?)

Copy link
Member

Choose a reason for hiding this comment

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

It looks like there is a string.prototype.trim. Is there a reason to keep the whitespace at the end of the string?

}

private getFormattedTextOfNode(node: Node, sourceFile: SourceFile, pos: number, useIndentationFromFile: boolean, options: ChangeNodeOptions) {
const nonFormattedText = getNonformattedText(node, sourceFile, this.newLine);
Copy link
Member

Choose a reason for hiding this comment

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

"nonFormatted" but "Nonformatted"?

Copy link
Member Author

Choose a reason for hiding this comment

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

👍

? change.options.indentation
: change.useIndentationFromFile
? formatting.SmartIndenter.getIndentation(change.range.pos, sourceFile, formatOptions, posStartsLine || (change.options.prefix === this.newLineCharacter))
options.indentation !== undefined
Copy link
Member

Choose a reason for hiding this comment

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

The first two lines are just options.indentation ||, aren't they?

Copy link
Member Author

Choose a reason for hiding this comment

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

options.identation could be 0, which is falsy

@@ -83,14 +83,18 @@ namespace ts.textChanges {
delta?: number;
}

export interface ChangeNodesOptions {
nodesSeparator?: string;
Copy link
Member

Choose a reason for hiding this comment

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

Is this also applicable to replaceNodeRange?

Copy link
Member Author

Choose a reason for hiding this comment

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

More accurate but impossibly-verbose names:
ChangeNodesOptions -> OptionsWhenInsertingMoreThanOneNewNode
ChangeNodeOptions -> OptionsWhenInsertingOneNode

Copy link
Member

Choose a reason for hiding this comment

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

I believe you've cleaned this up offline.

@amcasey
Copy link
Member

amcasey commented Aug 3, 2017

It seems to be impossible to extract a single identifier into a method. Is that intentional? e.g. I might want to wrap access to a variable in a function so that I can bracket it with EH or logging.

return __return;
}
function newFunction(i) {
return { i, __return: i++ };
Copy link
Member

Choose a reason for hiding this comment

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

You assign i before i++ happens. Meaning at the call site, i will not actually have the incremented value. In this case, that's fine because you return immediately, but here's a case that breaks:

function foo() {
    var i = 10;
    
    function inner() {
        return i++;  // extract this line into file scope, the console output changes
    }

    inner();
    console.log(i);
} 

foo();

Copy link
Member

Choose a reason for hiding this comment

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

I agree that __return should be computed first.

if (edits[j].span.start >= edits[i].span.start) {
edits[j].span.start += editDelta;
}
}
Copy link
Member

Choose a reason for hiding this comment

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

Could you eliminate the need to do this by walking the list backwards?

if (negative && isAvailable) {
this.raiseError(`verifyApplicableRefactorAvailableForRange failed - expected no refactor but found some.`);
}
if (!negative && !isAvailable) {
Copy link
Member

Choose a reason for hiding this comment

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

if [](start = 12, length = 2)

else if

@@ -2736,6 +2749,26 @@ namespace FourSlash {
}
}

public verifyRefactorAvailable(negative: boolean, name?: string, subName?: string) {
Copy link
Member

Choose a reason for hiding this comment

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

negative [](start = 39, length = 8)

We seem to have an existing pattern, but this is a particularly pointed example of using a predicate with a negative name.

Copy link
Member Author

Choose a reason for hiding this comment

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

This is to unify the verify.something and verify.not.something functions

@@ -2752,6 +2785,20 @@ namespace FourSlash {
}
}

public applyRefactor(refactorName: string, actionName: string) {
const range = { pos: this.currentCaretPosition, end: this.selectionEnd === -1 ? this.currentCaretPosition : this.selectionEnd };
Copy link
Member

Choose a reason for hiding this comment

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

{ pos: this.currentCaretPosition, end: this.selectionEnd === -1 ? this.currentCaretPosition : this.selectionEnd } [](start = 26, length = 113)

Duplicated from above?

return new ChangeTracker(getNewlineKind(context), context.rulesProvider);
}

public static fromRefactorContext(context: RefactorContext) {
Copy link
Member

Choose a reason for hiding this comment

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

fromRefactorContext [](start = 22, length = 19)

Can this be merged with fromCodeFixContext?

return this;
}

private replaceWithMutiple(sourceFile: SourceFile, startPosition: number, endPosition: number, newNodes: ReadonlyArray<Node>, options: ChangeMultipleNodesOptions): this {
Copy link
Member

Choose a reason for hiding this comment

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

replaceWithMutiple [](start = 16, length = 18)

Typo

const parts = change.nodes.map(n => this.getFormattedTextOfNode(n, sourceFile, pos, options));
text = parts.join(change.options.nodeSeparator);
}
else {
Copy link
Member

Choose a reason for hiding this comment

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

else { [](start = 12, length = 6)

Would it be worthwhile to assert the kind so that we get some kind of warning if a new one is added?

text = this.getFormattedTextOfNode(change.node, sourceFile, pos, options);
}
// strip initial indentation (spaces or tabs) if text will be inserted in the middle of the line
// however keep indentation if it is was forced
Copy link
Member

Choose a reason for hiding this comment

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

Is this comment still accurate?

? formatting.SmartIndenter.getIndentation(change.range.pos, sourceFile, formatOptions, posStartsLine || (change.options.prefix === this.newLineCharacter))
options.indentation !== undefined
? options.indentation
: (options.useIndentationFromFile !== false)
Copy link
Member

Choose a reason for hiding this comment

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

(options.useIndentationFromFile !== false) [](start = 22, length = 42)

!options.useIndentationFromFile?

Copy link
Member Author

Choose a reason for hiding this comment

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

We want true and undefined to behave the same here, hence the odd conditional

({ a } = newFunction(a));
}

function newFunction(a: any) {
Copy link
Member

Choose a reason for hiding this comment

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

any [](start = 32, length = 3)

number?

Copy link
Member Author

Choose a reason for hiding this comment

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

Something's going weird with the unit test harness setup - this gets : number when I do it in the editor

export interface I { x: number };
class C {
a() {
let z = 1;
Copy link
Member

Choose a reason for hiding this comment

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

let z = 1; [](start = 11, length = 11)

Did you intend to consume z or is this a way to confirm that it's ignored?

Copy link
Member Author

Choose a reason for hiding this comment

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

I didn't write it, but I'd go with the latter

@minestarks
Copy link
Member

Type is referenced from context where it's not visible: minestarks@d1fa06e#diff-92a31e9489e4badbd019ff88953fe79f

@amcasey
Copy link
Member

amcasey commented Aug 4, 2017

function M() {
    let a = [1, 2, 3]
    let x = 0
    a[x++]++
}

Extract a[x++]++ into the containing scope.

function M() {
    let a = [1, 2, 3]
    let x = 0
        ({ a, x } = newFunction(a, x));
}
function newFunction(a: number[], x: number) {
    a[x++]++;
    return { a, x };
}
  1. There's a syntax error because of the missing semicolon between 0 and (.
  2. AFAIK, there's no reason to assign to a.

@amcasey
Copy link
Member

amcasey commented Aug 4, 2017

I suspect @minestarks or I may already have reported this, but if you extract a[x] into a global scope, x is not passed as a parameter and fails to bind.

function M() {
    let a = [1,2,3];
    let x = 0;
    a[x];
}

@RyanCavanaugh RyanCavanaugh mentioned this pull request Aug 4, 2017
@RyanCavanaugh
Copy link
Member Author

Fresh sheet of paper at #17625

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

9 participants