Skip to content

Commit

Permalink
feat: add convention option to rule attribute-names (#207)
Browse files Browse the repository at this point in the history

---------

Co-authored-by: 43081j <[email protected]>
  • Loading branch information
jpradelle and 43081j authored Aug 29, 2024
1 parent 50f05d2 commit 8e18aae
Show file tree
Hide file tree
Showing 4 changed files with 307 additions and 4 deletions.
52 changes: 51 additions & 1 deletion docs/rules/attribute-names.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Attributes are always treated lowercase, but it is common to have camelCase
property names. In these situations, an explicit lowercase attribute should
be supplied.

Further, camelCase names should ideally be exposed as snake-case attributes.
Further, camelCase names should ideally be exposed as kebab-case attributes.

## Rule Details

Expand Down Expand Up @@ -33,6 +33,56 @@ The following patterns are not warnings:
@property({attribute: 'camel-case-name'})
camelCaseName: string;

@property({attribute: 'camel-case-other-name'})
camelCaseName: string;

@property()
lower: string;
```

## Options

### `convention`

You can specify a `convention` to enforce a particular naming convention
on element attributes.

The available values are:

- `none` (default, no convention is enforced)
- `kebab`
- `snake`

For example for a property named `camelCaseProp`, expected attribute names are:

| Convention | Attribute |
|------------|----------------------|
| none | any lower case value |
| kebab | camel-case-prop |
| snake | camel_case_prop |

The following patterns are considered warnings with `{"convention": "kebab"}`
specified:

```ts
// Should have an attribute set to `camel-case-name`
@property() camelCaseName: string;

// Attribute should match the property name when a convention is set
@property({attribute: 'camel-case-other-name'})
camelCaseName: string;
```

The following patterns are not warnings with `{"convention": "kebab"}`
specified:

```ts
@property({attribute: 'camel-case-name'})
camelCaseName: string;

@property({attribute: false})
camelCaseName: string;

@property()
lower: string;
```
Expand Down
48 changes: 45 additions & 3 deletions src/rules/attribute-names.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import {Rule} from 'eslint';
import * as ESTree from 'estree';
import {getPropertyMap, isLitClass} from '../util';
import {getPropertyMap, isLitClass, toKebabCase, toSnakeCase} from '../util';

//------------------------------------------------------------------------------
// Rule Definition
Expand All @@ -18,19 +18,32 @@ const rule: Rule.RuleModule = {
recommended: true,
url: 'https://github.com/43081j/eslint-plugin-lit/blob/master/docs/rules/attribute-names.md'
},
schema: [],
schema: [
{
type: 'object',
properties: {
convention: {type: 'string', enum: ['none', 'kebab', 'snake']}
},
additionalProperties: false
}
],
messages: {
casedAttribute:
'Attributes are case-insensitive and therefore should be ' +
'defined in lower case',
casedPropertyWithoutAttribute:
'Property has non-lowercase casing but no attribute. It should ' +
'instead have an explicit `attribute` set to the lower case ' +
'name (usually snake-case)'
'name (usually snake-case)',
casedAttributeConvention:
'Attribute should be property name written in {{convention}} ' +
'as "{{name}}"'
}
},

create(context): Rule.RuleListener {
const convention = context.options[0]?.convention ?? 'none';

return {
ClassDeclaration: (node: ESTree.Class): void => {
if (isLitClass(node)) {
Expand All @@ -57,6 +70,35 @@ const rule: Rule.RuleModule = {
node: propConfig.expr ?? propConfig.key,
messageId: 'casedAttribute'
});
} else if (convention !== 'none') {
let conventionName;
let expectedAttributeName;

switch (convention) {
case 'snake':
conventionName = 'snake_case';
expectedAttributeName = toSnakeCase(prop);
break;
case 'kebab':
conventionName = 'kebab-case';
expectedAttributeName = toKebabCase(prop);
break;
}

if (
expectedAttributeName &&
conventionName &&
propConfig.attributeName !== expectedAttributeName
) {
context.report({
node: propConfig.expr ?? propConfig.key,
messageId: 'casedAttributeConvention',
data: {
convention: conventionName,
name: expectedAttributeName
}
});
}
}
}
}
Expand Down
191 changes: 191 additions & 0 deletions src/test/rules/attribute-names_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,76 @@ ruleTester.run('attribute-names', rule, {
};
}
}`,
{
code: `class Foo extends LitElement {
static get properties() {
return {
camelCase: {type: String, attribute: 'camel-case'}
};
}
}`,
options: [{convention: 'kebab'}]
},
{
code: `class Foo extends LitElement {
static get properties() {
return {
camelCase: {type: String, attribute: false}
};
}
}`,
options: [{convention: 'kebab'}]
},
{
code: `class Foo extends LitElement {
static get properties() {
return {
camelCase: {type: String, attribute: 'camelcase'}
};
}
}`,
options: [{convention: 'none'}]
},
{
code: `class Foo extends LitElement {
static get properties() {
return {
camelCase: {type: String, attribute: 'camel-case'}
};
}
}`,
options: [{convention: 'none'}]
},
{
code: `class Foo extends LitElement {
static get properties() {
return {
camelCase: {type: String, attribute: false}
};
}
}`,
options: [{convention: 'none'}]
},
{
code: `class Foo extends LitElement {
static get properties() {
return {
camelCase: {type: String, attribute: 'camel_case'}
};
}
}`,
options: [{convention: 'snake'}]
},
{
code: `class Foo extends LitElement {
static get properties() {
return {
camelCase: {type: String, attribute: false}
};
}
}`,
options: [{convention: 'snake'}]
},
{
code: `class Foo extends LitElement {
@property({ type: String })
Expand Down Expand Up @@ -95,6 +165,127 @@ ruleTester.run('attribute-names', rule, {
}
]
},
{
code: `class Foo extends LitElement {
static get properties() {
return {
camelCase: {type: String}
};
}
}`,
options: [{convention: 'kebab'}],
errors: [
{
line: 4,
column: 13,
messageId: 'casedPropertyWithoutAttribute'
}
]
},
{
code: `class Foo extends LitElement {
static get properties() {
return {
camelCase: {type: String, attribute: 'stillCamelCase'}
};
}
}`,
options: [{convention: 'kebab'}],
errors: [
{
line: 4,
column: 24,
messageId: 'casedAttribute'
}
]
},
{
code: `class Foo extends LitElement {
static get properties() {
return {
camelCase: {type: String, attribute: 'wrong-name'}
};
}
}`,
options: [{convention: 'kebab'}],
errors: [
{
line: 4,
column: 24,
messageId: 'casedAttributeConvention',
data: {convention: 'kebab-case', name: 'camel-case'}
}
]
},
{
code: `class Foo extends LitElement {
static get properties() {
return {
camelCase: {type: String}
};
}
}`,
options: [{convention: 'none'}],
errors: [
{
line: 4,
column: 13,
messageId: 'casedPropertyWithoutAttribute'
}
]
},
{
code: `class Foo extends LitElement {
static get properties() {
return {
camelCase: {type: String, attribute: 'camelCase'}
};
}
}`,
options: [{convention: 'none'}],
errors: [
{
line: 4,
column: 24,
messageId: 'casedAttribute'
}
]
},
{
code: `class Foo extends LitElement {
static get properties() {
return {
camelCase: {type: String}
};
}
}`,
options: [{convention: 'snake'}],
errors: [
{
line: 4,
column: 13,
messageId: 'casedPropertyWithoutAttribute'
}
]
},
{
code: `class Foo extends LitElement {
static get properties() {
return {
camelCase: {type: String, attribute: 'wrong-name'}
};
}
}`,
options: [{convention: 'snake'}],
errors: [
{
line: 4,
column: 24,
messageId: 'casedAttributeConvention',
data: {convention: 'snake_case', name: 'camel_case'}
}
]
},
{
code: `class Foo extends LitElement {
static get properties() {
Expand Down
20 changes: 20 additions & 0 deletions src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -355,3 +355,23 @@ export function templateExpressionToHtml(

return html;
}

/**
* Converts a camelCase string to snake_case string
*
* @param {string} camelCaseStr String to convert
* @return {string}
*/
export function toSnakeCase(camelCaseStr: string): string {
return camelCaseStr.replace(/[A-Z]/g, (m) => '_' + m.toLowerCase());
}

/**
* Converts a camelCase string to kebab-case string
*
* @param {string} camelCaseStr String to convert
* @return {string}
*/
export function toKebabCase(camelCaseStr: string): string {
return camelCaseStr.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase());
}

0 comments on commit 8e18aae

Please sign in to comment.