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

[labs/analyzer] Analyze overloaded functions and methods. #3702

Merged
merged 19 commits into from
Mar 6, 2023
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
2923990
Add tests for overloaded function exports and methods.
bicknellr Feb 28, 2023
2262994
Ignore non-implementation signatures of overloaded functions.
bicknellr Feb 28, 2023
324191a
Make the string concatenation in the overloaded example function more…
bicknellr Feb 28, 2023
2d570d6
Add different JSDoc comment combinations for overloaded functions.
bicknellr Feb 28, 2023
d8e004a
Add tests for overloaded function JSDoc comments.
bicknellr Feb 28, 2023
d726897
Read JSDoc tags from another declaration if there are none on a funct…
bicknellr Mar 1, 2023
7290b67
Extract `getDocNodeForFunctionLikeDeclaration`.
bicknellr Mar 1, 2023
a8c92e4
Add missing JSDoc tags to JS properties test fixture.
bicknellr Mar 1, 2023
666e657
Add support for retrieving JSDoc descriptions from method overload de…
bicknellr Mar 1, 2023
839d1e8
Add a test for JSDoc descriptions on multiple overload signatures.
bicknellr Mar 1, 2023
4bca6fa
Add a changeset.
bicknellr Mar 1, 2023
049b531
Add `FunctionOverloadDeclaration`.
bicknellr Mar 2, 2023
fb57847
Don't use documentation from one signature for another.
bicknellr Mar 3, 2023
8b5b4e9
Merge remote-tracking branch 'origin/main' into analyzer-overloaded-f…
bicknellr Mar 3, 2023
4bd8d46
Collect function overloads.
bicknellr Mar 3, 2023
7cadbac
Fix tests for overloaded non-method functions.
bicknellr Mar 3, 2023
4755340
Fix overloaded methods.
bicknellr Mar 3, 2023
591c4ea
Add a comment about checking for `.body` on method declarations.
bicknellr Mar 3, 2023
2feb32b
Update the changeset.
bicknellr Mar 3, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
12 changes: 8 additions & 4 deletions packages/labs/analyzer/src/lib/javascript/classes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ import {
hasExportModifier,
getPrivacy,
} from '../utils.js';
import {getFunctionLikeInfo} from './functions.js';
import {
getDocNodeForFunctionLikeDeclaration,
getFunctionLikeInfo,
} from './functions.js';
import {getTypeForNode} from '../types.js';
import {
isCustomElementSubclass,
Expand Down Expand Up @@ -76,14 +79,15 @@ export const getClassMembers = (
const methodMap = new Map<string, ClassMethod>();
const staticMethodMap = new Map<string, ClassMethod>();
declaration.members.forEach((node) => {
if (ts.isMethodDeclaration(node)) {
if (ts.isMethodDeclaration(node) && node.body) {
bicknellr marked this conversation as resolved.
Show resolved Hide resolved
const info = getMemberInfo(node);
const docNode = getDocNodeForFunctionLikeDeclaration(node, analyzer);
(info.static ? staticMethodMap : methodMap).set(
node.name.getText(),
new ClassMethod({
...info,
...getFunctionLikeInfo(node, analyzer),
...parseNodeJSDocInfo(node),
...getFunctionLikeInfo(node, docNode, analyzer),
...parseNodeJSDocInfo(docNode),
})
);
} else if (ts.isPropertyDeclaration(node)) {
Expand Down
38 changes: 32 additions & 6 deletions packages/labs/analyzer/src/lib/javascript/functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,26 @@ import {getTypeForNode, getTypeForType} from '../types.js';
import {parseJSDocDescription, parseNodeJSDocInfo} from './jsdoc.js';
import {hasDefaultModifier, hasExportModifier} from '../utils.js';

export const getDocNodeForFunctionLikeDeclaration = (
declaration: ts.FunctionLikeDeclaration,
analyzer: AnalyzerInterface
) => {
if (ts.getJSDocTags(declaration).length !== 0) {
return declaration;
}

// Overloaded functions have mulitple declaration nodes. If there are no
// JSDoc tags on the provided declaration, use the first one that does have
// JSDoc tags for the purpose of extracting a description.
Copy link
Collaborator

Choose a reason for hiding this comment

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

Copy link
Member Author

Choose a reason for hiding this comment

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

Apparently having any overload signatures hides the implementation signature w.r.t. type checking call sites, so I guess from that perspective it makes sense that the implementation signature's docs aren't ever shown for a given call site.

const type = analyzer.program.getTypeChecker().getTypeAtLocation(declaration);
const allDeclarations = type.getSymbol()?.getDeclarations();
return (
(allDeclarations as Array<ts.FunctionLikeDeclaration> | undefined)?.find(
(x) => ts.getJSDocTags(x).length !== 0
) ?? declaration
);
};

/**
* Returns the name of a function declaration.
*/
Expand Down Expand Up @@ -58,10 +78,11 @@ const getFunctionDeclaration = (
name: string,
analyzer: AnalyzerInterface
): FunctionDeclaration => {
const docNode = getDocNodeForFunctionLikeDeclaration(declaration, analyzer);
return new FunctionDeclaration({
name,
...parseNodeJSDocInfo(declaration),
...getFunctionLikeInfo(declaration, analyzer),
...parseNodeJSDocInfo(docNode),
...getFunctionLikeInfo(declaration, docNode, analyzer),
});
};

Expand All @@ -70,20 +91,24 @@ const getFunctionDeclaration = (
*/
export const getFunctionLikeInfo = (
node: ts.FunctionLikeDeclaration,
docNode: ts.FunctionLikeDeclaration,
analyzer: AnalyzerInterface
) => {
return {
parameters: node.parameters.map((p) => getParameter(p, analyzer)),
return: getReturn(node, analyzer),
parameters: node.parameters.map((p, i) =>
getParameter(p, docNode.parameters[i], analyzer)
bicknellr marked this conversation as resolved.
Show resolved Hide resolved
),
return: getReturn(node, docNode, analyzer),
};
};

const getParameter = (
param: ts.ParameterDeclaration,
docNode: ts.ParameterDeclaration,
analyzer: AnalyzerInterface
): Parameter => {
const paramTag = ts.getAllJSDocTagsOfKind(
param,
docNode,
ts.SyntaxKind.JSDocParameterTag
)[0];
const p: Parameter = {
Expand All @@ -108,10 +133,11 @@ const getParameter = (

const getReturn = (
node: ts.FunctionLikeDeclaration,
docNode: ts.FunctionLikeDeclaration,
analyzer: AnalyzerInterface
): Return => {
const returnTag = ts.getAllJSDocTagsOfKind(
node,
docNode,
ts.SyntaxKind.JSDocReturnTag
)[0];
const signature = analyzer.program
Expand Down
4 changes: 3 additions & 1 deletion packages/labs/analyzer/src/lib/javascript/modules.ts
Original file line number Diff line number Diff line change
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`.
Copy link
Collaborator

Choose a reason for hiding this comment

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

Can you maybe expand on this a bit? Like why do we want to ignore overloads? What do we do with different docs on different overloads? I don't the the CEM format explicitly handles overloads, so if we generated multiple declarations with the same name but different parameters, that would basically work by default - with the exception that tools might expect names to be unique.

I could see us documenting in the CEM that names aren't unique and duplicates indicate overloads (though we might want a way to mark and/or hide the implementation declaration because its signature might not be pretty).

Copy link
Member Author

Choose a reason for hiding this comment

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

IIRC, I went down this path because there are few places in the analyzer that currently require unique names (and I just assumed this was a requirement). Kevin's comment on my other PR about getField() is related. Another example is here, where declarations in modules are collected:

throw new Error(
`Internal error: duplicate declaration '${name}' in ${sourceFile.fileName}`
);

I think your suggestion of explicitly documenting that names of potentially overloadable items in collections are not necessarily unique sounds like the best way to go. That way I could remove the weird documentation merging stuff here and we would avoid losing information in the output. Conveniently, the CEM schema already seems prepared for this since most (all?) collections of declarations are arrays of items with name properties and not an object / map that requires a unique key per item. This would also resolve the issue you pointed out in your other comment about this PR not matching TS's behavior around docs for overloads.

Copy link
Collaborator

Choose a reason for hiding this comment

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

which would move the responsibility for matching TS's behavior or not to consumers of the manifest. That sounds good to me.

} else if (ts.isFunctionDeclaration(statement) && statement.body) {
addDeclaration(getFunctionDeclarationInfo(statement, analyzer));
} else if (ts.isVariableStatement(statement)) {
getVariableDeclarationInfo(statement, analyzer).forEach(addDeclaration);
Expand Down
88 changes: 88 additions & 0 deletions packages/labs/analyzer/src/test/javascript/functions_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,5 +82,93 @@ for (const lang of languages) {
assert.equal(fn.deprecated, undefined);
});

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 function has an overloaded signature in TS.`
);
assert.equal(fn.summary, undefined);
assert.equal(fn.parameters?.length, 1);
assert.equal(fn.parameters?.[0].name, 'x');
assert.equal(
fn.parameters?.[0].description,
'Some value, 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);
});

test('Overloaded function with docs only on a non-implementation signature', ({
module,
}) => {
const exportedFn = module.getResolvedExport(
'overloadedWithDocsOnOverloadOnly'
);
const fn = module.getDeclaration('overloadedWithDocsOnOverloadOnly');
assert.equal(fn, exportedFn);
assert.ok(fn?.isFunctionDeclaration());
assert.equal(fn.name, `overloadedWithDocsOnOverloadOnly`);
assert.equal(
fn.description?.replace(/\n/g, ' '),
`This is not the implementation signature, but there are no docs on the implementation signature.`
);
assert.equal(fn.summary, undefined);
assert.equal(fn.parameters?.length, 1);
assert.equal(fn.parameters?.[0].name, 'x');
assert.equal(
fn.parameters?.[0].description?.replace(/\n/g, ' '),
'This might be a string or a number, even though this signature only allows strings.'
);
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?.replace(/\n/g, ' '),
'Returns either a string or a number, but this signature only mentions `string`.'
);
assert.equal(fn.deprecated, undefined);
});

test('Overloaded function with docs on many signatures', ({module}) => {
const exportedFn = module.getResolvedExport('overloadedWithDocsOnMany');
const fn = module.getDeclaration('overloadedWithDocsOnMany');
assert.equal(fn, exportedFn);
assert.ok(fn?.isFunctionDeclaration());
assert.equal(fn.name, `overloadedWithDocsOnMany`);
assert.equal(fn.description, `This is the implementation signature.`);
assert.equal(fn.summary, undefined);
assert.equal(fn.parameters?.length, 1);
assert.equal(fn.parameters?.[0].name, 'x');
assert.equal(
fn.parameters?.[0].description,
'Maybe a string, maybe 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?.replace(/\n/g, ' '),
'Returns either a string or a number, depending on the mood.'
);
assert.equal(fn.deprecated, undefined);
});

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

test('method with an overloaded signature and docs on a implementation 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 function has an overloaded signature in TS.`
);
assert.equal(fn.summary, undefined);
assert.equal(fn.parameters?.length, 1);
assert.equal(fn.parameters?.[0].name, 'x');
assert.equal(
fn.parameters?.[0].description,
'Some value, 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);
});

test('method with an overloaded signature and docs only on an overload signature', ({
element,
}) => {
const fn = Array.from(element.methods).find(
(m) => m.name === 'overloadedWithDocsOnOverloadOnly'
);
assert.ok(fn?.isFunctionDeclaration());
assert.equal(fn.name, `overloadedWithDocsOnOverloadOnly`);
assert.equal(
fn.description?.replace(/\n/g, ' '),
`This is not the implementation signature, but there are no docs on the implementation signature.`
);
assert.equal(fn.summary, undefined);
assert.equal(fn.parameters?.length, 1);
assert.equal(fn.parameters?.[0].name, 'x');
assert.equal(
fn.parameters?.[0].description?.replace(/\n/g, ' '),
'This might be a string or a number, even though this signature only allows strings.'
);
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?.replace(/\n/g, ' '),
'Returns either a string or a number, but this signature only mentions `string`.'
);
assert.equal(fn.deprecated, undefined);
});

test('method with an overloaded signature and docs on many overload signatures', ({
element,
}) => {
const fn = Array.from(element.methods).find(
(m) => m.name === 'overloadedWithDocsOnMany'
);
assert.ok(fn?.isFunctionDeclaration());
assert.equal(fn.name, `overloadedWithDocsOnMany`);
assert.equal(
fn.description?.replace(/\n/g, ' '),
`This is the implementation signature.`
);
assert.equal(fn.summary, undefined);
assert.equal(fn.parameters?.length, 1);
assert.equal(fn.parameters?.[0].name, 'x');
assert.equal(
fn.parameters?.[0].description?.replace(/\n/g, ' '),
'Maybe a string, maybe 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?.replace(/\n/g, ' '),
'Returns either a string or a number, depending on the mood.'
);
assert.equal(fn.deprecated, undefined);
});

test.run();
}
43 changes: 43 additions & 0 deletions packages/labs/analyzer/test-files/js/functions/functions.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,46 @@ export function function2(a, b = false, ...c) {
export default function () {
return 'default';
}

/**
* This function has an overloaded signature in TS.
Copy link
Collaborator

Choose a reason for hiding this comment

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

What does that mean exactly? Is this file associated with ts/functions/src/functions.ts so that those overloads apply here? I would have thought that these were separate modules.

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, the same tests are run twice (once for each analysis result from files in the ts and js directories) so the doc comments that those tests check for need to match. For overloaded functions in particular, the comments for the single declaration in JS need to match those from the particular overload that they're extracted from in TS. These could be rewritten to make what's happening here clearer though.

However, it also sounds like I need to update this PR to handle overloads by producing multiple entries rather than trying to merge into a single one and I'm not sure how this is going to affect what's in the JS version of these fixtures yet. I'll try to make these more self-explanatory once I know what's happening with them.

* @param {string | number} x Some value, 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;
}
}

/**
* This is not the implementation signature, but there are no docs on the
* implementation signature.
* @param {string | number} x This might be a string or a number, even though
* this signature only allows strings.
* @returns {string | number} Returns either a string or a number, but this
* signature only mentions `string`.
*/
export function overloadedWithDocsOnOverloadOnly(x) {
if (typeof x === 'string') {
return x + 'abc';
} else {
return x + 123;
}
}

/**
* This is the implementation signature.
* @param {string | number} x Maybe a string, maybe a number.
* @returns {string | number} Returns either a string or a number, depending on
* the mood.
*/
export function overloadedWithDocsOnMany(x) {
if (typeof x === 'string') {
return x + 'abc';
} else {
return x + 123;
}
}