Skip to content

JavaScript prototype class inference #5876

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

Merged
merged 15 commits into from
Dec 14, 2015
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 62 additions & 7 deletions src/compiler/binder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,8 +222,21 @@ namespace ts {
case SyntaxKind.ExportAssignment:
return (<ExportAssignment>node).isExportEquals ? "export=" : "default";
case SyntaxKind.BinaryExpression:
// Binary expression case is for JS module 'module.exports = expr'
return "export=";
switch (getSpecialPropertyAssignmentKind(node)) {
case SpecialPropertyAssignmentKind.ModuleExports:
// module.exports = ...
return "export=";
case SpecialPropertyAssignmentKind.ExportsProperty:
case SpecialPropertyAssignmentKind.ThisProperty:
// exports.x = ... or this.y = ...
return ((node as BinaryExpression).left as PropertyAccessExpression).name.text;
case SpecialPropertyAssignmentKind.PrototypeProperty:
// className.prototype.methodName = ...
return (((node as BinaryExpression).left as PropertyAccessExpression).expression as PropertyAccessExpression).name.text;
}
Debug.fail("Unknown binary declaration kind");
break;

case SyntaxKind.FunctionDeclaration:
case SyntaxKind.ClassDeclaration:
return node.flags & NodeFlags.Default ? "default" : undefined;
Expand Down Expand Up @@ -1166,11 +1179,25 @@ namespace ts {
return checkStrictModeIdentifier(<Identifier>node);
case SyntaxKind.BinaryExpression:
if (isInJavaScriptFile(node)) {
if (isExportsPropertyAssignment(node)) {
bindExportsPropertyAssignment(<BinaryExpression>node);
}
else if (isModuleExportsAssignment(node)) {
bindModuleExportsAssignment(<BinaryExpression>node);
const specialKind = getSpecialPropertyAssignmentKind(node);
switch (specialKind) {
case SpecialPropertyAssignmentKind.ExportsProperty:
bindExportsPropertyAssignment(<BinaryExpression>node);
break;
case SpecialPropertyAssignmentKind.ModuleExports:
bindModuleExportsAssignment(<BinaryExpression>node);
break;
case SpecialPropertyAssignmentKind.PrototypeProperty:
bindPrototypePropertyAssignment(<BinaryExpression>node);
break;
case SpecialPropertyAssignmentKind.ThisProperty:
bindThisPropertyAssignment(<BinaryExpression>node);
break;
case SpecialPropertyAssignmentKind.None:
// Nothing to do
break;
default:
Debug.fail("Unknown special property assignment kind");
}
}
return checkStrictModeBinaryExpression(<BinaryExpression>node);
Expand Down Expand Up @@ -1339,6 +1366,34 @@ namespace ts {
bindExportAssignment(node);
}

function bindThisPropertyAssignment(node: BinaryExpression) {
// Declare a 'member' in case it turns out the container was an ES5 class
if (container.kind === SyntaxKind.FunctionExpression || container.kind === SyntaxKind.FunctionDeclaration) {
container.symbol.members = container.symbol.members || {};
declareSymbol(container.symbol.members, container.symbol, node, SymbolFlags.Property, SymbolFlags.PropertyExcludes);
}
}

function bindPrototypePropertyAssignment(node: BinaryExpression) {
// We saw a node of the form 'x.prototype.y = z'. Declare a 'member' y on x if x was a function.

// Look up the function in the local scope, since prototype assignments should
// follow the function declaration
const classId = <Identifier>(<PropertyAccessExpression>(<PropertyAccessExpression>node.left).expression).expression;
const funcSymbol = container.locals[classId.text];
Copy link
Member

Choose a reason for hiding this comment

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

This doesn't guarantee that the prototype assignment immediately follows. Also, what if classId denotes a non-function, such as a variable or an interface declaration? I'm thinking you should instead check that the prototype assignment is immediately contained in a statement list and that the immediately preceding statement is a function declaration.

if (!funcSymbol || !(funcSymbol.flags & SymbolFlags.Function)) {
return;
}

// Set up the members collection if it doesn't exist already
if (!funcSymbol.members) {
funcSymbol.members = {};
}

// Declare the method/property
declareSymbol(funcSymbol.members, funcSymbol, <PropertyAccessExpression>node.left, SymbolFlags.Property, SymbolFlags.PropertyExcludes);
}

function bindCallExpression(node: CallExpression) {
// We're only inspecting call expressions to detect CommonJS modules, so we can skip
// this check if we've already seen the module indicator
Expand Down
40 changes: 36 additions & 4 deletions src/compiler/checker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2664,9 +2664,18 @@ namespace ts {
if (declaration.kind === SyntaxKind.BinaryExpression) {
return links.type = checkExpression((<BinaryExpression>declaration).right);
}
// Handle exports.p = expr
if (declaration.kind === SyntaxKind.PropertyAccessExpression) {
return checkExpressionCached((<BinaryExpression>declaration.parent).right);
// Declarations only exist for property access expressions for certain
// special assignment kinds
if (declaration.parent.kind === SyntaxKind.BinaryExpression) {
// Handle exports.p = expr or this.p = expr or className.prototype.method = expr
return links.type = checkExpressionCached((<BinaryExpression>declaration.parent).right);
}
else {
// Declaration for className.prototype in inferred JS class
const type = createAnonymousType(symbol, symbol.members, emptyArray, emptyArray, undefined, undefined);
Copy link
Member

Choose a reason for hiding this comment

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

See my previous comment about caching.

Copy link
Member

Choose a reason for hiding this comment

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

Actually, I'm not sure I understand what case this code is handling.

return links.type = type;
}
}
// Handle variable, parameter or property
if (!pushTypeResolution(symbol, TypeSystemPropertyName.Type)) {
Expand Down Expand Up @@ -6888,6 +6897,23 @@ namespace ts {
const symbol = getSymbolOfNode(container.parent);
return container.flags & NodeFlags.Static ? getTypeOfSymbol(symbol) : (<InterfaceType>getDeclaredTypeOfSymbol(symbol)).thisType;
}

// If this is a function in a JS file, it might be a class method. Check if it's the RHS
// of a x.prototype.y = function [name]() { .... }
if (isInJavaScriptFile(node) && container.kind === SyntaxKind.FunctionExpression) {
if (getSpecialPropertyAssignmentKind(container.parent) === SpecialPropertyAssignmentKind.PrototypeProperty) {
// Get the 'x' of 'x.prototype.y = f' (here, 'f' is 'container')
const className = (((container.parent as BinaryExpression) // x.protoype.y = f
.left as PropertyAccessExpression) // x.prototype.y
.expression as PropertyAccessExpression) // x.prototype
.expression; // x
const classSymbol = checkExpression(className).symbol;
if (classSymbol.members) {
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 this needs to be if (classSymbol && classSymbol.members).

return createAnonymousType(undefined, classSymbol.members, emptyArray, emptyArray, /*stringIndexType*/ undefined, /*numberIndexType*/ undefined);
Copy link
Member

Choose a reason for hiding this comment

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

See my previous comment about caching.

}
}
}

return anyType;
}

Expand Down Expand Up @@ -9630,8 +9656,14 @@ namespace ts {
declaration.kind !== SyntaxKind.ConstructSignature &&
declaration.kind !== SyntaxKind.ConstructorType) {

// When resolved signature is a call signature (and not a construct signature) the result type is any
if (compilerOptions.noImplicitAny) {
// When resolved signature is a call signature (and not a construct signature) the result type is any, unless
// the declaring function had members created through 'x.prototype.y = expr' or 'this.y = expr' psuedodeclarations
// in a JS file
const funcSymbol = checkExpression(node.expression).symbol;
if (funcSymbol && funcSymbol.members && (funcSymbol.flags & SymbolFlags.Function)) {
return createAnonymousType(undefined, funcSymbol.members, emptyArray, emptyArray, /*stringIndexType*/ undefined, /*numberIndexType*/ undefined);
Copy link
Member

Choose a reason for hiding this comment

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

You need to create this type once and cache it. Right now you're creating a brand new type every time which allocates a lot of objects and defeats all the type comparison caching we do.

}
else if (compilerOptions.noImplicitAny) {
error(node, Diagnostics.new_expression_whose_target_lacks_a_construct_signature_implicitly_has_an_any_type);
}
return anyType;
Expand Down
13 changes: 13 additions & 0 deletions src/compiler/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2290,6 +2290,19 @@ namespace ts {
// It is optional because in contextual signature instantiation, nothing fails
}

/* @internal */
export const enum SpecialPropertyAssignmentKind {
None,
/// exports.name = expr
ExportsProperty,
/// module.exports = expr
ModuleExports,
/// className.prototype.name = expr
PrototypeProperty,
/// this.name = expr
ThisProperty
}

export interface DiagnosticMessage {
key: string;
category: DiagnosticCategory;
Expand Down
59 changes: 33 additions & 26 deletions src/compiler/utilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1063,33 +1063,40 @@ namespace ts {
(<CallExpression>expression).arguments[0].kind === SyntaxKind.StringLiteral;
}

/**
* Returns true if the node is an assignment to a property on the identifier 'exports'.
* This function does not test if the node is in a JavaScript file or not.
*/
export function isExportsPropertyAssignment(expression: Node): boolean {
// of the form 'exports.name = expr' where 'name' and 'expr' are arbitrary
return isInJavaScriptFile(expression) &&
(expression.kind === SyntaxKind.BinaryExpression) &&
((<BinaryExpression>expression).operatorToken.kind === SyntaxKind.EqualsToken) &&
((<BinaryExpression>expression).left.kind === SyntaxKind.PropertyAccessExpression) &&
((<PropertyAccessExpression>(<BinaryExpression>expression).left).expression.kind === SyntaxKind.Identifier) &&
((<Identifier>((<PropertyAccessExpression>(<BinaryExpression>expression).left).expression)).text === "exports");
}
/// Given a BinaryExpression, returns SpecialPropertyAssignmentKind for the various kinds of property
/// assignments we treat as special in the binder
export function getSpecialPropertyAssignmentKind(expression: Node): SpecialPropertyAssignmentKind {
if (expression.kind !== SyntaxKind.BinaryExpression) {
return SpecialPropertyAssignmentKind.None;
}
const expr = <BinaryExpression>expression;
if (expr.operatorToken.kind !== SyntaxKind.EqualsToken || expr.left.kind !== SyntaxKind.PropertyAccessExpression) {
return SpecialPropertyAssignmentKind.None;
}
const lhs = <PropertyAccessExpression>expr.left;
if (lhs.expression.kind === SyntaxKind.Identifier) {
const lhsId = <Identifier>lhs.expression;
if (lhsId.text === "exports") {
// exports.name = expr
return SpecialPropertyAssignmentKind.ExportsProperty;
}
else if (lhsId.text === "module" && lhs.name.text === "exports") {
// module.exports = expr
return SpecialPropertyAssignmentKind.ModuleExports;
}
}
else if (lhs.expression.kind === SyntaxKind.ThisKeyword) {
return SpecialPropertyAssignmentKind.ThisProperty;
}
else if (lhs.expression.kind === SyntaxKind.PropertyAccessExpression) {
// chained dot, e.g. x.y.z = expr; this var is the 'x.y' part
const innerPropertyAccess = <PropertyAccessExpression>lhs.expression;
if (innerPropertyAccess.expression.kind === SyntaxKind.Identifier && innerPropertyAccess.name.text === "prototype") {
return SpecialPropertyAssignmentKind.PrototypeProperty;
}
}

/**
* Returns true if the node is an assignment to the property access expression 'module.exports'.
* This function does not test if the node is in a JavaScript file or not.
*/
export function isModuleExportsAssignment(expression: Node): boolean {
// of the form 'module.exports = expr' where 'expr' is arbitrary
return isInJavaScriptFile(expression) &&
(expression.kind === SyntaxKind.BinaryExpression) &&
((<BinaryExpression>expression).operatorToken.kind === SyntaxKind.EqualsToken) &&
((<BinaryExpression>expression).left.kind === SyntaxKind.PropertyAccessExpression) &&
((<PropertyAccessExpression>(<BinaryExpression>expression).left).expression.kind === SyntaxKind.Identifier) &&
((<Identifier>((<PropertyAccessExpression>(<BinaryExpression>expression).left).expression)).text === "module") &&
((<PropertyAccessExpression>(<BinaryExpression>expression).left).name.text === "exports");
return SpecialPropertyAssignmentKind.None;
}

export function getExternalModuleName(node: Node): Expression {
Expand Down
22 changes: 18 additions & 4 deletions src/harness/fourslash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1162,7 +1162,7 @@ namespace FourSlash {

public printCurrentQuickInfo() {
const quickInfo = this.languageService.getQuickInfoAtPosition(this.activeFile.fileName, this.currentCaretPosition);
Harness.IO.log(JSON.stringify(quickInfo));
Harness.IO.log("Quick Info: " + quickInfo.displayParts.map(part => part.text).join(""));
}

public printErrorList() {
Expand Down Expand Up @@ -1204,12 +1204,26 @@ namespace FourSlash {

public printMemberListMembers() {
const members = this.getMemberListAtCaret();
Harness.IO.log(JSON.stringify(members));
this.printMembersOrCompletions(members);
}

public printCompletionListMembers() {
const completions = this.getCompletionListAtCaret();
Harness.IO.log(JSON.stringify(completions));
this.printMembersOrCompletions(completions);
}

private printMembersOrCompletions(info: ts.CompletionInfo) {
function pad(s: string, length: number) {
return s + new Array(length - s.length + 1).join(" ");
}
function max<T>(arr: T[], selector: (x: T) => number): number {
return arr.reduce((prev, x) => Math.max(prev, selector(x)), 0);
}
const longestNameLength = max(info.entries, m => m.name.length);
const longestKindLength = max(info.entries, m => m.kind.length);
info.entries.sort((m, n) => m.sortText > n.sortText ? 1 : m.sortText < n.sortText ? -1 : m.name > n.name ? 1 : m.name < n.name ? -1 : 0);
const membersString = info.entries.map(m => `${pad(m.name, longestNameLength)} ${pad(m.kind, longestKindLength)} ${m.kindModifiers}`).join("\n");
Harness.IO.log(membersString);
}

public printReferences() {
Expand Down Expand Up @@ -3287,4 +3301,4 @@ namespace FourSlashInterface {
};
}
}
}
}
46 changes: 46 additions & 0 deletions tests/cases/fourslash/javaScriptPrototype1.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
///<reference path="fourslash.ts" />

// Assignments to the 'prototype' property of a function create a class

// @allowNonTsExtensions: true
// @Filename: myMod.js
//// function myCtor(x) {
//// }
//// myCtor.prototype.foo = function() { return 32 };
//// myCtor.prototype.bar = function() { return '' };
////
//// var m = new myCtor(10);
//// m/*1*/
//// var a = m.foo;
//// a/*2*/
//// var b = a();
//// b/*3*/
//// var c = m.bar();
//// c/*4*/


// Members of the class instance
goTo.marker('1');
edit.insert('.');
verify.memberListContains('foo', undefined, undefined, 'property');
verify.memberListContains('bar', undefined, undefined, 'property');
edit.backspace();

// Members of a class method (1)
goTo.marker('2');
edit.insert('.');
verify.memberListContains('length', undefined, undefined, 'property');
edit.backspace();

// Members of the invocation of a class method (1)
goTo.marker('3');
edit.insert('.');
verify.memberListContains('toFixed', undefined, undefined, 'method');
verify.not.memberListContains('substr', undefined, undefined, 'method');
edit.backspace();

// Members of the invocation of a class method (2)
goTo.marker('4');
edit.insert('.');
verify.memberListContains('substr', undefined, undefined, 'method');
verify.not.memberListContains('toFixed', undefined, undefined, 'method');
36 changes: 36 additions & 0 deletions tests/cases/fourslash/javaScriptPrototype2.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
///<reference path="fourslash.ts" />

// Assignments to 'this' in the constructorish body create
// properties with those names

// @allowNonTsExtensions: true
// @Filename: myMod.js
//// function myCtor(x) {
//// this.qua = 10;
//// }
//// myCtor.prototype.foo = function() { return 32 };
//// myCtor.prototype.bar = function() { return '' };
////
//// var m = new myCtor(10);
//// m/*1*/
//// var x = m.qua;
//// x/*2*/
//// myCtor/*3*/

// Verify the instance property exists
goTo.marker('1');
edit.insert('.');
verify.completionListContains('qua', undefined, undefined, 'property');
edit.backspace();

// Verify the type of the instance property
goTo.marker('2');
edit.insert('.');
verify.completionListContains('toFixed', undefined, undefined, 'method');

goTo.marker('3');
edit.insert('.');
// Make sure symbols don't leak out into the constructor
verify.completionListContains('qua', undefined, undefined, 'warning');
verify.completionListContains('foo', undefined, undefined, 'warning');
verify.completionListContains('bar', undefined, undefined, 'warning');
20 changes: 20 additions & 0 deletions tests/cases/fourslash/javaScriptPrototype3.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
///<reference path="fourslash.ts" />

// Inside an inferred method body, the type of 'this' is the class type

// @allowNonTsExtensions: true
// @Filename: myMod.js
//// function myCtor(x) {
//// this.qua = 10;
//// }
//// myCtor.prototype.foo = function() { return this/**/; };
//// myCtor.prototype.bar = function() { return '' };
////

goTo.marker();
edit.insert('.');

// Check members of the function
verify.completionListContains('foo', undefined, undefined, 'property');
verify.completionListContains('bar', undefined, undefined, 'property');
verify.completionListContains('qua', undefined, undefined, 'property');
Loading