Skip to content

Commit

Permalink
feat (define-tag-after-class-definition): new rule
Browse files Browse the repository at this point in the history
  • Loading branch information
43081j committed May 20, 2023
1 parent f3d0c5f commit 8c1f0cc
Show file tree
Hide file tree
Showing 4 changed files with 245 additions and 12 deletions.
89 changes: 89 additions & 0 deletions src/rules/define-tag-after-class-definition.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/**
* @fileoverview Enforces that the `define(...)` call happens after the
* associated class has been defined
* @author James Garbutt <https://github.com/43081j>
*/

import {Rule} from 'eslint';
import * as ESTree from 'estree';
import {isCustomElement, isDefineCall} from '../util';

//------------------------------------------------------------------------------
// Rule Definition
//------------------------------------------------------------------------------

const rule: Rule.RuleModule = {
meta: {
docs: {
description:
'Enforces that the `define(...)` call happens after' +
' the associated class has been defined',
url: 'https://github.com/43081j/eslint-plugin-wc/blob/master/docs/rules/define-tag-after-class-definition.md'
},
messages: {
unregistered:
'Custom element class has not been registered with ' +
' a `customElements.define` call',
noExpressions:
'Custom element classes should not be declared inline. ' +
'They should be exported as concrete class declarations.'
}
},

create(context): Rule.RuleListener {
const seenClasses = new Set<ESTree.Node>();
const source = context.getSourceCode();

//----------------------------------------------------------------------
// Helpers
//----------------------------------------------------------------------

//----------------------------------------------------------------------
// Public
//----------------------------------------------------------------------

return {
'ClassDeclaration,ClassExpression': (node: ESTree.Class): void => {
if (
isCustomElement(context, node, source.getJSDocComment(node)) &&
node.id?.type === 'Identifier'
) {
seenClasses.add(node);
}
},
CallExpression: (node: ESTree.CallExpression): void => {
const tagClass = node.arguments[1];

if (isDefineCall(node)) {
if (tagClass.type === 'Identifier') {
const ref = context
.getScope()
.references.find((r) => r.identifier.name === tagClass.name);

if (ref?.resolved && ref.resolved.defs.length === 1) {
seenClasses.delete(ref.resolved.defs[0].node);
}
} else if (tagClass.type === 'ClassExpression') {
seenClasses.delete(tagClass);
context.report({
node: tagClass,
messageId: 'noExpressions'
});
}
}
},
'Program:exit': (): void => {
for (const node of seenClasses) {
context.report({
node,
messageId: 'unregistered'
});
}

seenClasses.clear();
}
};
}
};

export default rule;
14 changes: 2 additions & 12 deletions src/rules/no-invalid-element-name.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {Rule} from 'eslint';
import * as ESTree from 'estree';
import isValidElementName = require('is-valid-element-name');
import {knownNamespaces} from '../util/tag-names';
import {isDefineCall} from '../util';

//------------------------------------------------------------------------------
// Rule Definition
Expand Down Expand Up @@ -82,18 +83,7 @@ const rule: Rule.RuleModule = {

return {
CallExpression: (node: ESTree.CallExpression): void => {
if (
node.callee.type === 'MemberExpression' &&
((node.callee.object.type === 'MemberExpression' &&
node.callee.object.object.type === 'Identifier' &&
node.callee.object.object.name === 'window' &&
node.callee.object.property.type === 'Identifier' &&
node.callee.object.property.name === 'customElements') ||
(node.callee.object.type === 'Identifier' &&
node.callee.object.name === 'customElements')) &&
node.callee.property.type === 'Identifier' &&
node.callee.property.name === 'define'
) {
if (isDefineCall(node)) {
const firstArg = node.arguments[0];
if (
firstArg &&
Expand Down
134 changes: 134 additions & 0 deletions src/test/rules/define-tag-after-class-definition_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/**
* @fileoverview Enforces that the `define(...)` call happens after the
* associated class has been defined
* @author James Garbutt <https://github.com/43081j>
*/

//------------------------------------------------------------------------------
// Requirements
//------------------------------------------------------------------------------

import rule from '../../rules/define-tag-after-class-definition';
import {RuleTester} from 'eslint';

//------------------------------------------------------------------------------
// Tests
//------------------------------------------------------------------------------

const ruleTester = new RuleTester({
parserOptions: {
sourceType: 'module',
ecmaVersion: 2015
}
});

ruleTester.run('define-tag-after-class-definition', rule, {
valid: [
'class Foo {}',
`class Foo extends HTMLElement {}
customElements.define('x-foo', Foo);`,
`class Foo extends HTMLElement {}
const x = 303;
customElements.define('x-foo', Foo);`,
`class Foo extends HTMLElement {}
window.customElements.define('x-foo', Foo);`,
`/** @customElement x-foo */
class Foo extends Bar {}
customElements.define('x-foo', Foo);`,
`customElements.define('x-foo', Foo);`,
`const Foo = doSomeBlackBoxThing();
customElements.define('x-foo', Foo);`,
{
code: `class Foo extends Bar {}
customElements.define('x-foo', Foo);`,
settings: {
wc: {
elementBaseClasses: ['Bar']
}
}
}
],

invalid: [
{
code: 'class Foo extends HTMLElement {}',
errors: [
{
messageId: 'unregistered',
line: 1,
column: 1
}
]
},
{
code: `class Foo extends HTMLElement {}
customElements.define('x-foo', someThingElse);`,
errors: [
{
messageId: 'unregistered',
line: 1,
column: 1
}
]
},
{
code: `class Foo extends HTMLElement {}
customElements.define('x-foo', someDynamicThing());`,
errors: [
{
messageId: 'unregistered',
line: 1,
column: 1
}
]
},
{
code: `class Foo extends HTMLElement {}
someOtherNonsense.define('x-foo', Foo);`,
errors: [
{
messageId: 'unregistered',
line: 1,
column: 1
}
]
},
{
code: `customElements.define('x-foo', class extends HTMLElement {
});`,
errors: [
{
messageId: 'noExpressions',
line: 1,
column: 32
}
]
},
{
code: 'class Foo extends Bar {}',
settings: {
wc: {
elementBaseClasses: ['Bar']
}
},
errors: [
{
messageId: 'unregistered',
line: 1,
column: 1
}
]
},
{
code: `/** @customElement x-foo */
class Foo extends Bar {}`,
errors: [
{
messageId: 'unregistered',
line: 2,
column: 7
}
]
}
]
});
20 changes: 20 additions & 0 deletions src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,23 @@ export function isNativeCustomElement(node: ESTree.Class): boolean {
node.superClass.name === 'HTMLElement'
);
}

/**
* Determines if a call expression is a `customElements.define` call
* @param {ESTree.CallExpression} node Node to test
* @return {boolean}
*/
export function isDefineCall(node: ESTree.CallExpression): boolean {
return (
node.callee.type === 'MemberExpression' &&
((node.callee.object.type === 'MemberExpression' &&
node.callee.object.object.type === 'Identifier' &&
node.callee.object.object.name === 'window' &&
node.callee.object.property.type === 'Identifier' &&
node.callee.object.property.name === 'customElements') ||
(node.callee.object.type === 'Identifier' &&
node.callee.object.name === 'customElements')) &&
node.callee.property.type === 'Identifier' &&
node.callee.property.name === 'define'
);
}

0 comments on commit 8c1f0cc

Please sign in to comment.