Skip to content

Commit

Permalink
[labs/analyzer] Analyze overloaded functions and methods. (#3702)
Browse files Browse the repository at this point in the history
* Add tests for overloaded function exports and methods.

* Ignore non-implementation signatures of overloaded functions.

* Make the string concatenation in the overloaded example function more obvious.

* Add different JSDoc comment combinations for overloaded functions.

* Add tests for overloaded function JSDoc comments.

* Read JSDoc tags from another declaration if there are none on a function's implementation node.

* Extract `getDocNodeForFunctionLikeDeclaration`.

* Add missing JSDoc tags to JS properties test fixture.

* Add support for retrieving JSDoc descriptions from method overload declarations.

* Add a test for JSDoc descriptions on multiple overload signatures.

* Add a changeset.

* Add `FunctionOverloadDeclaration`.

* Don't use documentation from one signature for another.

* Collect function overloads.

* Fix tests for overloaded non-method functions.

* Fix overloaded methods.

* Add a comment about checking for `.body` on method declarations.

* Update the changeset.
  • Loading branch information
bicknellr committed Mar 6, 2023
1 parent cabc618 commit 520b471
Show file tree
Hide file tree
Showing 11 changed files with 295 additions and 7 deletions.
8 changes: 8 additions & 0 deletions .changeset/early-berries-live.md
@@ -0,0 +1,8 @@
---
'@lit-labs/analyzer': minor
---

Adds support for overloaded functions. Methods of model objects that accept a
string key will now specifically return the `FunctionDeclaration` of the
implementation signature of an overloaded function, which has a new `overloads`
field containing a `FunctionOverloadDeclaration` for each overload signature.
9 changes: 6 additions & 3 deletions packages/labs/analyzer/src/lib/javascript/classes.ts
Expand Up @@ -81,13 +81,16 @@ export const getClassMembers = (
const methodMap = new Map<string, ClassMethod>();
const staticMethodMap = new Map<string, ClassMethod>();
declaration.members.forEach((node) => {
if (ts.isMethodDeclaration(node)) {
// Ignore non-implementation signatures of overloaded methods by checking
// for `node.body`.
if (ts.isMethodDeclaration(node) && node.body) {
const info = getMemberInfo(node);
const name = node.name.getText();
(info.static ? staticMethodMap : methodMap).set(
node.name.getText(),
name,
new ClassMethod({
...info,
...getFunctionLikeInfo(node, analyzer),
...getFunctionLikeInfo(node, name, analyzer),
...parseNodeJSDocInfo(node),
})
);
Expand Down
35 changes: 32 additions & 3 deletions packages/labs/analyzer/src/lib/javascript/functions.ts
Expand Up @@ -16,6 +16,8 @@ import {
AnalyzerInterface,
DeclarationInfo,
FunctionDeclaration,
FunctionLikeInit,
FunctionOverloadDeclaration,
Parameter,
Return,
} from '../model.js';
Expand Down Expand Up @@ -68,9 +70,8 @@ export const getFunctionDeclaration = (
docNode?: ts.Node
): FunctionDeclaration => {
return new FunctionDeclaration({
name,
...parseNodeJSDocInfo(docNode ?? declaration),
...getFunctionLikeInfo(declaration, analyzer),
...getFunctionLikeInfo(declaration, name, analyzer),
});
};

Expand All @@ -79,11 +80,39 @@ export const getFunctionDeclaration = (
*/
export const getFunctionLikeInfo = (
node: ts.FunctionLikeDeclaration,
name: string,
analyzer: AnalyzerInterface
) => {
): FunctionLikeInit => {
let overloads = undefined;
if (node.body) {
// Overloaded functions have multiple declaration nodes.
const type = analyzer.program.getTypeChecker().getTypeAtLocation(node);
const overloadDeclarations = type
.getSymbol()
?.getDeclarations()
?.filter((x) => x !== node) as Array<ts.FunctionLikeDeclaration>;

overloads = overloadDeclarations?.map((overload) => {
const info = getFunctionLikeInfo(overload, name, analyzer);
return new FunctionOverloadDeclaration({
// `docNode ?? overload` isn't needed here because TS doesn't allow
// const function assignments to be overloaded as of now.
...parseNodeJSDocInfo(overload),

// `info` can't be spread because `FunctionLikeInit` has an `overloads`
// property, even though it's always `undefined` in this case.
name: info.name,
parameters: info.parameters,
return: info.return,
});
});
}

return {
name,
parameters: node.parameters.map((p) => getParameter(p, analyzer)),
return: getReturn(node, analyzer),
overloads,
};
};

Expand Down
4 changes: 3 additions & 1 deletion packages/labs/analyzer/src/lib/javascript/modules.ts
Expand Up @@ -117,7 +117,9 @@ export const getModule = (
for (const statement of sourceFile.statements) {
if (ts.isClassDeclaration(statement)) {
addDeclaration(getClassDeclarationInfo(statement, analyzer));
} else if (ts.isFunctionDeclaration(statement)) {
// Ignore non-implementation signatures of overloaded functions by
// checking for `statement.body`.
} else if (ts.isFunctionDeclaration(statement) && statement.body) {
addDeclaration(getFunctionDeclarationInfo(statement, analyzer));
} else if (ts.isVariableStatement(statement)) {
getVariableDeclarationInfo(statement, analyzer).forEach(addDeclaration);
Expand Down
14 changes: 14 additions & 0 deletions packages/labs/analyzer/src/lib/model.ts
Expand Up @@ -330,15 +330,29 @@ export interface FunctionLikeInit extends DeprecatableDescribed {
name: string;
parameters?: Parameter[] | undefined;
return?: Return | undefined;
overloads?: FunctionOverloadDeclaration[] | undefined;
}

export class FunctionDeclaration extends Declaration {
parameters?: Parameter[] | undefined;
return?: Return | undefined;
overloads?: FunctionOverloadDeclaration[] | undefined;
constructor(init: FunctionLikeInit) {
super(init);
this.parameters = init.parameters;
this.return = init.return;
this.overloads = init.overloads;
}
}

export interface FunctionLikeOverloadInit extends FunctionLikeInit {
overloads?: undefined;
}

export class FunctionOverloadDeclaration extends FunctionDeclaration {
override overloads: undefined;
constructor(init: FunctionLikeOverloadInit) {
super(init);
}
}

Expand Down
79 changes: 79 additions & 0 deletions packages/labs/analyzer/src/test/javascript/functions_test.ts
Expand Up @@ -179,5 +179,84 @@ for (const lang of languages) {
assert.equal(fn.deprecated, 'Async function deprecated');
});

test('Overloaded function', ({module}) => {
const exportedFn = module.getResolvedExport('overloaded');
const fn = module.getDeclaration('overloaded');
assert.equal(fn, exportedFn);
assert.ok(fn?.isFunctionDeclaration());
assert.equal(fn.name, 'overloaded');
assert.equal(
fn.description,
'This signature works with strings or numbers.'
);
assert.equal(fn.summary, undefined);
assert.equal(fn.parameters?.length, 1);
assert.equal(fn.parameters?.[0].name, 'x');
assert.equal(
fn.parameters?.[0].description,
'Accepts either a string or a number.'
);
assert.equal(fn.parameters?.[0].summary, undefined);
assert.equal(fn.parameters?.[0].type?.text, 'string | number');
assert.equal(fn.parameters?.[0].default, undefined);
assert.equal(fn.parameters?.[0].rest, false);
assert.equal(fn.return?.type?.text, 'string | number');
assert.equal(
fn.return?.description,
'Returns either a string or a number.'
);
assert.equal(fn.deprecated, undefined);

// TODO: Run the same assertions in both languages once TS supports
// `@overload` for JSDoc in JS.
// <https://devblogs.microsoft.com/typescript/announcing-typescript-5-0-rc/#overload-support-in-jsdoc>
if (lang === 'ts') {
assert.ok(fn.overloads);
assert.equal(fn.overloads.length, 2);

assert.equal(fn.overloads[0].name, 'overloaded');
assert.equal(
fn.overloads[0].description,
'This signature only works with strings.'
);
assert.equal(fn.overloads[0].summary, undefined);
assert.equal(fn.overloads[0].parameters?.length, 1);
assert.equal(fn.overloads[0].parameters?.[0].name, 'x');
assert.equal(
fn.overloads[0].parameters?.[0].description,
'Accepts a string.'
);
assert.equal(fn.overloads[0].parameters?.[0].summary, undefined);
assert.equal(fn.overloads[0].parameters?.[0].type?.text, 'string');
assert.equal(fn.overloads[0].parameters?.[0].default, undefined);
assert.equal(fn.overloads[0].parameters?.[0].rest, false);
assert.equal(fn.overloads[0].return?.type?.text, 'string');
assert.equal(fn.overloads[0].return?.description, 'Returns a string.');
assert.equal(fn.overloads[0].deprecated, undefined);

assert.equal(fn.overloads[1].name, 'overloaded');
assert.equal(
fn.overloads[1].description,
'This signature only works with numbers.'
);
assert.equal(fn.overloads[1].summary, undefined);
assert.equal(fn.overloads[1].parameters?.length, 1);
assert.equal(fn.overloads[1].parameters?.[0].name, 'x');
assert.equal(
fn.overloads[1].parameters?.[0].description,
'Accepts a number.'
);
assert.equal(fn.overloads[1].parameters?.[0].summary, undefined);
assert.equal(fn.overloads[1].parameters?.[0].type?.text, 'number');
assert.equal(fn.overloads[1].parameters?.[0].default, undefined);
assert.equal(fn.overloads[1].parameters?.[0].rest, false);
assert.equal(fn.overloads[1].return?.type?.text, 'number');
assert.equal(fn.overloads[1].return?.description, 'Returns a number.');
assert.equal(fn.overloads[1].deprecated, undefined);
} else {
assert.equal(fn.overloads?.length ?? 0, 0);
}
});

test.run();
}
77 changes: 77 additions & 0 deletions packages/labs/analyzer/src/test/lit-element/properties_test.ts
Expand Up @@ -191,5 +191,82 @@ for (const lang of languages) {
assert.equal(property.attribute, 'static-prop');
});

test('method with an overloaded signature', ({element}) => {
const fn = Array.from(element.methods).find((m) => m.name === 'overloaded');
assert.ok(fn?.isFunctionDeclaration());
assert.equal(fn.name, 'overloaded');
assert.equal(
fn.description,
'This signature works with strings or numbers.'
);
assert.equal(fn.summary, undefined);
assert.equal(fn.parameters?.length, 1);
assert.equal(fn.parameters?.[0].name, 'x');
assert.equal(
fn.parameters?.[0].description,
'Accepts either a string or a number.'
);
assert.equal(fn.parameters?.[0].summary, undefined);
assert.equal(fn.parameters?.[0].type?.text, 'string | number');
assert.equal(fn.parameters?.[0].default, undefined);
assert.equal(fn.parameters?.[0].rest, false);
assert.equal(fn.return?.type?.text, 'string | number');
assert.equal(
fn.return?.description,
'Returns either a string or a number.'
);
assert.equal(fn.deprecated, undefined);

// TODO: Run the same assertions in both languages once TS supports
// `@overload` for JSDoc in JS.
// <https://devblogs.microsoft.com/typescript/announcing-typescript-5-0-rc/#overload-support-in-jsdoc>
if (lang === 'ts') {
assert.ok(fn.overloads);
assert.equal(fn.overloads.length, 2);

assert.equal(fn.overloads[0].name, 'overloaded');
assert.equal(
fn.overloads[0].description,
'This signature only works with strings.'
);
assert.equal(fn.overloads[0].summary, undefined);
assert.equal(fn.overloads[0].parameters?.length, 1);
assert.equal(fn.overloads[0].parameters?.[0].name, 'x');
assert.equal(
fn.overloads[0].parameters?.[0].description,
'Accepts a string.'
);
assert.equal(fn.overloads[0].parameters?.[0].summary, undefined);
assert.equal(fn.overloads[0].parameters?.[0].type?.text, 'string');
assert.equal(fn.overloads[0].parameters?.[0].default, undefined);
assert.equal(fn.overloads[0].parameters?.[0].rest, false);
assert.equal(fn.overloads[0].return?.type?.text, 'string');
assert.equal(fn.overloads[0].return?.description, 'Returns a string.');
assert.equal(fn.overloads[0].deprecated, undefined);

assert.equal(fn.overloads[1].name, 'overloaded');
assert.equal(
fn.overloads[1].description,
'This signature only works with numbers.'
);
assert.equal(fn.overloads[1].summary, undefined);
assert.equal(fn.overloads[1].parameters?.length, 1);
assert.equal(fn.overloads[1].parameters?.[0].name, 'x');
assert.equal(
fn.overloads[1].parameters?.[0].description,
'Accepts a number.'
);
assert.equal(fn.overloads[1].parameters?.[0].summary, undefined);
assert.equal(fn.overloads[1].parameters?.[0].type?.text, 'number');
assert.equal(fn.overloads[1].parameters?.[0].default, undefined);
assert.equal(fn.overloads[1].parameters?.[0].rest, false);
assert.equal(fn.overloads[1].return?.type?.text, 'number');
assert.equal(fn.overloads[1].return?.description, 'Returns a number.');
assert.equal(fn.overloads[1].deprecated, undefined);
} else {
assert.equal(fn.overloads?.length ?? 0, 0);
}
});

test.run();
}
13 changes: 13 additions & 0 deletions packages/labs/analyzer/test-files/js/functions/functions.js
Expand Up @@ -87,3 +87,16 @@ export const asyncFunction = async (a) => {
await 0;
return a;
};

/**
* This signature works with strings or numbers.
* @param {string | number} x Accepts either a string or a number.
* @returns {string | number} Returns either a string or a number.
*/
export function overloaded(x) {
if (typeof x === 'string') {
return x + 'abc';
} else {
return x + 123;
}
}
13 changes: 13 additions & 0 deletions packages/labs/analyzer/test-files/js/properties/element-a.js
Expand Up @@ -56,5 +56,18 @@ export class ElementA extends LitElement {
this.globalClass = document.createElement('foo');
this.staticProp = 42;
}

/**
* This signature works with strings or numbers.
* @param {string | number} x Accepts either a string or a number.
* @returns {string | number} Returns either a string or a number.
*/
overloaded(x) {
if (typeof x === 'string') {
return x + 'abc';
} else {
return x + 123;
}
}
}
customElements.define('element-a', ElementA);
25 changes: 25 additions & 0 deletions packages/labs/analyzer/test-files/ts/functions/src/functions.ts
Expand Up @@ -91,3 +91,28 @@ export const asyncFunction = async (a: string) => {
await 0;
return a;
};

/**
* This signature only works with strings.
* @param x Accepts a string.
* @returns Returns a string.
*/
export function overloaded(x: string): string;
/**
* This signature only works with numbers.
* @param x Accepts a number.
* @returns Returns a number.
*/
export function overloaded(x: number): number;
/**
* This signature works with strings or numbers.
* @param x Accepts either a string or a number.
* @returns Returns either a string or a number.
*/
export function overloaded(x: string | number): string | number {
if (typeof x === 'string') {
return x + 'abc';
} else {
return x + 123;
}
}
25 changes: 25 additions & 0 deletions packages/labs/analyzer/test-files/ts/properties/src/element-a.ts
Expand Up @@ -80,4 +80,29 @@ export class ElementA extends LitElement {

@property()
union: LocalClass | HTMLElement | ImportedClass;

/**
* This signature only works with strings.
* @param x Accepts a string.
* @returns Returns a string.
*/
overloaded(x: string): string;
/**
* This signature only works with numbers.
* @param x Accepts a number.
* @returns Returns a number.
*/
overloaded(x: number): number;
/**
* This signature works with strings or numbers.
* @param x Accepts either a string or a number.
* @returns Returns either a string or a number.
*/
overloaded(x: string | number): string | number {
if (typeof x === 'string') {
return x + 'abc';
} else {
return x + 123;
}
}
}

0 comments on commit 520b471

Please sign in to comment.