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鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(eslint-plugin): [unbound-method] support bound builtins #1526

Merged
merged 7 commits into from Feb 3, 2020
68 changes: 68 additions & 0 deletions packages/eslint-plugin/src/rules/unbound-method.ts
Expand Up @@ -18,6 +18,59 @@ export type Options = [Config];

export type MessageIds = 'unbound';

const nativelyBoundMembers = ([
'Promise',
bradzacher marked this conversation as resolved.
Show resolved Hide resolved
'Number',
'Object',
'String', // eslint-disable-line @typescript-eslint/internal/prefer-ast-types-enum
'RegExp',
'Symbol',
'Array',
'Proxy',
'Date',
'Infinity',
bradzacher marked this conversation as resolved.
Show resolved Hide resolved
'Atomics',
'Reflect',
'console',
'Math',
'JSON',
'Intl',
] as const)
.map(namespace => {
const object = global[namespace];
return Object.getOwnPropertyNames(object)
.filter(
name =>
!name.startsWith('_') &&
typeof (object as Record<string, unknown>)[name] === 'function',
)
.map(name => `${namespace}.${name}`);
})
.reduce((arr, names) => arr.concat(names), []);

const isMemberNotImported = (
symbol: ts.Symbol,
currentSourceFile: ts.SourceFile | undefined,
): boolean => {
const { valueDeclaration } = symbol;
if (!valueDeclaration) {
// working around https://github.com/microsoft/TypeScript/issues/31294
return false;
}

return (
!!currentSourceFile &&
currentSourceFile !== valueDeclaration.getSourceFile()
);
};

const getNodeName = (node: TSESTree.Node): string | null =>
node.type === AST_NODE_TYPES.Identifier ? node.name : null;

const getMemberFullName = (
node: TSESTree.MemberExpression | TSESTree.OptionalMemberExpression,
): string => `${getNodeName(node.object)}.${getNodeName(node.property)}`;

export default util.createRule<Options, MessageIds>({
name: 'unbound-method',
meta: {
Expand Down Expand Up @@ -53,6 +106,9 @@ export default util.createRule<Options, MessageIds>({
create(context, [{ ignoreStatic }]) {
const parserServices = util.getParserServices(context);
const checker = parserServices.program.getTypeChecker();
const currentSourceFile = parserServices.program.getSourceFile(
context.getFilename(),
);

return {
'MemberExpression, OptionalMemberExpression'(
Expand All @@ -62,6 +118,18 @@ export default util.createRule<Options, MessageIds>({
return;
}

const objectSymbol = checker.getSymbolAtLocation(
parserServices.esTreeNodeToTSNodeMap.get(node.object),
);

if (
objectSymbol &&
nativelyBoundMembers.includes(getMemberFullName(node)) &&
isMemberNotImported(objectSymbol, currentSourceFile)
) {
return;
}

const originalNode = parserServices.esTreeNodeToTSNodeMap.get(node);
const symbol = checker.getSymbolAtLocation(originalNode);

Expand Down
3 changes: 3 additions & 0 deletions packages/eslint-plugin/tests/fixtures/class.ts
@@ -1,2 +1,5 @@
// used by no-throw-literal test case to validate custom error
export class Error {}

// used by unbound-method test case to test imports
export const console = { log() {} };
36 changes: 36 additions & 0 deletions packages/eslint-plugin/tests/rules/unbound-method.test.ts
Expand Up @@ -7,6 +7,7 @@ const rootPath = getFixturesRootDir();
const ruleTester = new RuleTester({
parser: '@typescript-eslint/parser',
parserOptions: {
sourceType: 'module',
tsconfigRootDir: rootPath,
project: './tsconfig.json',
},
Expand Down Expand Up @@ -43,6 +44,10 @@ function addContainsMethodsClassInvalid(

ruleTester.run('unbound-method', rule, {
valid: [
'Promise.resolve().then(console.log)',
'["1", "2", "3"].map(Number.parseInt)',
'[5.2, 7.1, 3.6].map(Math.floor);',
'const x = console.log',
...[
'instance.bound();',
'instance.unbound();',
Expand Down Expand Up @@ -208,6 +213,37 @@ if(myCondition || x.mightBeDefined) {
`,
],
invalid: [
{
code: `
class Console {
log(str) {
process.stdout.write(str);
}
}

const console = new Console();

Promise.resolve().then(console.log);
`,
errors: [
{
line: 10,
messageId: 'unbound',
},
],
},
{
code: `
import { console } from './class';
const x = console.log;
`,
errors: [
{
line: 3,
messageId: 'unbound',
},
],
},
{
code: addContainsMethodsClass(`
function foo(arg: ContainsMethods | null) {
Expand Down
8 changes: 8 additions & 0 deletions packages/eslint-plugin/typings/node.d.ts
@@ -0,0 +1,8 @@
// augment nodejs global with ES2015+ things
declare namespace NodeJS {
interface Global {
Atomics: typeof Atomics;
Proxy: typeof Proxy;
Reflect: typeof Reflect;
}
}