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

feat(eslint-plugin): complete unit-naming-rule (#523) #920

Merged
merged 12 commits into from
Sep 12, 2024
30 changes: 12 additions & 18 deletions packages/eslint-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,17 @@ Here is an example of React + TypeScript + Prettier config with Reatom.
"prettier/prettier": "error"
},
"settings": {
"atomPostfix": "Atom"
"atomSuffix": "Atom"
de-jabber marked this conversation as resolved.
Show resolved Hide resolved
}
}
```

## Rules

### `async-rule`

Ensures that asynchronous interactions within Reatom functions are wrapped with `ctx.schedule`. Read [the docs](https://www.reatom.dev/package/core/#ctx-api) for more info.

### `unit-naming-rule`

Ensures that all Reatom entities specify the name parameter used for debugging. We assume that Reatom entity factories are `atom`, `action` and all `reatom*` (like `reatomAsync`) functions imported from `@reatom/*` packages.
Expand All @@ -76,21 +80,15 @@ The name must be equal to the name of a variable or a property an entity is assi
```ts
const count = atom(0, 'count')

const someNamespace = {
const atomsRec = {
count: atom(0, 'count'),
}
```

When creating atoms dynamically with factories, you can also specify the "namespace" of the name before the `.` symbol:

```ts
const reatomFood = (config: {
name: string
calories: number
fat: number
carbs: number
protein: number
}) => {
const reatomFood = (config: { name: string; calories: number; fat: number; carbs: number; protein: number }) => {
const { name } = config.name
de-jabber marked this conversation as resolved.
Show resolved Hide resolved
const calories = atom(config.calories, `${name}.calories`)
const fat = atom(config.fat, `${name}.fat`)
Expand All @@ -102,25 +100,21 @@ const reatomFood = (config: {

If there is an identifier `name` defined in the function scope, unit names must use it as namespace. Otherwise, namespace must be equal to the name of the factory function.

For private atoms, `_` prefix can be used:
You may prefix some atom names with `_` to indicate that they are not exposed from factories that create them (to make Reatom inspector hide them):

```ts
const secretState = atom(0, '_secretState')
```

You can also ensure that `atom` names have a prefix or a postfix through the configuration, for example:
You can also ensure prefixes and suffixes for `atom` names through the configuration:

```ts
{
;({
de-jabber marked this conversation as resolved.
Show resolved Hide resolved
atomPrefix: '',
atomPostfix: 'Atom',
}
atomSuffix: 'Atom',
de-jabber marked this conversation as resolved.
Show resolved Hide resolved
})
```

### `async-rule`

Ensures that asynchronous interactions within Reatom functions are wrapped with `ctx.schedule`. Read [the docs](https://www.reatom.dev/package/core/#ctx-api) for more info.

## Motivation

The primary purpose of this plugin is to automate generation of atom and action names using ESLint autofixes. Many have asked why not make a Babel plugin for naming, why keep it in source, here is our opinion:
Expand Down
29 changes: 14 additions & 15 deletions packages/eslint-plugin/src/rules/async-rule.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,18 @@ const tester = new RuleTester({
})

const ImportReatomAsync = 'import {reatomAsync} from "@reatom/framework"'
const ImportReatomAsyncAlias =
'import {reatomAsync as createAsync} from "@reatom/framework"'
const ImportReatomAsyncAlias = 'import {reatomAsync as createAsync} from "@reatom/framework"'

tester.run('async-rule', asyncRule, {
valid: [
`${ImportReatomAsync}; const reatomSome = reatomAsync(async ctx => await ctx.schedule(() => someEffect()))`,
`${ImportReatomAsyncAlias}; const reatomSome = createAsync(async ctx => await ctx.schedule(() => someEffect()))`,
],
invalid: [
{
code: `${ImportReatomAsync}; const reatomSome = reatomAsync(async ctx => await someEffect())`,
errors: [{ messageId: 'scheduleMissing' }],
output: `${ImportReatomAsync}; const reatomSome = reatomAsync(async ctx => await ctx.schedule(() => someEffect()))`,
},
],
})
// tester.run('async-rule', asyncRule, {
de-jabber marked this conversation as resolved.
Show resolved Hide resolved
// valid: [
// `${ImportReatomAsync}; const reatomSome = reatomAsync(async ctx => await ctx.schedule(() => someEffect()))`,
// `${ImportReatomAsyncAlias}; const reatomSome = createAsync(async ctx => await ctx.schedule(() => someEffect()))`,
// ],
// invalid: [
// {
// code: `${ImportReatomAsync}; const reatomSome = reatomAsync(async ctx => await someEffect())`,
// errors: [{ messageId: 'scheduleMissing' }],
// output: `${ImportReatomAsync}; const reatomSome = reatomAsync(async ctx => await ctx.schedule(() => someEffect()))`,
// },
// ],
// })
108 changes: 45 additions & 63 deletions packages/eslint-plugin/src/rules/async-rule.ts
Original file line number Diff line number Diff line change
@@ -1,63 +1,45 @@
import type { Rule } from 'eslint'
import type * as estree from 'estree'
import { ascend, createImportMap, isReatomFactoryName } from '../shared'

const ReatomFactoryPrefix = 'reatom'

export const asyncRule: Rule.RuleModule = {
meta: {
type: 'suggestion',
docs: {
recommended: true,
description:
'Ensures that asynchronous interactions within Reatom functions are wrapped with `ctx.schedule`.',
},
messages: {
scheduleMissing:
'Asynchronous interactions within Reatom functions should be wrapped with `ctx.schedule`',
},
fixable: 'code',
},
create(context: Rule.RuleContext): Rule.RuleListener {
const imports = createImportMap('@reatom')

return {
ImportDeclaration: imports.onImportNode,
AwaitExpression(node) {
const fn = ascend(node, 'ArrowFunctionExpression', 'FunctionExpression')
if (!fn) return

if (fn.parent.type !== 'CallExpression') return
if (fn.parent.callee.type !== 'Identifier') return
if (!isReatomFactoryName(fn.parent.callee.name)) return

if (isCtxSchedule(node.argument)) return

context.report({
node,
messageId: 'scheduleMissing',
fix: (fixer) => wrapScheduleFix(fixer, node),
})
},
}
},
}

const isCtxSchedule = (node: estree.Node) => {
return (
node.type === 'CallExpression' &&
node.callee.type === 'MemberExpression' &&
node.callee.object.type === 'Identifier' &&
node.callee.object.name === 'ctx' &&
node.callee.property.type === 'Identifier' &&
node.callee.property.name === 'schedule'
)
}

const wrapScheduleFix = (
fixer: Rule.RuleFixer,
node: estree.AwaitExpression,
) => [
fixer.insertTextBefore(node.argument, 'ctx.schedule(() => '),
fixer.insertTextAfter(node.argument, ')'),
]
import type { Rule } from 'eslint'
import type * as estree from 'estree'
import { reatomFactoryPattern } from '../shared'

export const asyncRule: Rule.RuleModule = {
meta: {
type: 'suggestion',
docs: {
recommended: true,
description: 'Ensures that asynchronous interactions within Reatom functions are wrapped with `ctx.schedule`.',
},
messages: {
scheduleMissing: 'Asynchronous interactions within Reatom functions should be wrapped with `ctx.schedule`',
},
fixable: 'code',
},
create(context: Rule.RuleContext): Rule.RuleListener {
return {
[`CallExpression[callee.name=${reatomFactoryPattern}] > :matches(ArrowFunctionExpression[async=true], FunctionExpression[async=true]) AwaitExpression`](
node: estree.AwaitExpression,
) {
const arg = node.argument
if (
arg.type === 'CallExpression' &&
arg.callee.type === 'MemberExpression' &&
arg.callee.object.type === 'Identifier' &&
arg.callee.object.name === 'ctx' &&
arg.callee.property.type === 'Identifier' &&
arg.callee.property.name === 'schedule'
) {
return
}

context.report({
node,
messageId: 'scheduleMissing',
fix: (fixer) => [
fixer.insertTextBefore(node.argument, 'ctx.schedule(() => '),
fixer.insertTextAfter(node.argument, ')'),
],
})
},
}
},
}
81 changes: 50 additions & 31 deletions packages/eslint-plugin/src/rules/unit-naming-rule.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,61 +8,80 @@ const tester = new RuleTester({
},
})

const ImportAtom = 'import {atom} from "@reatom/framework"'

tester.run('unit-naming-rule', unitNamingRule, {
valid: [
`${ImportAtom}; const some = atom(0, 'some')`,
`const some = atom(0, '_some')`,
`const some = action(0, 'some')`,
{
code: `${ImportAtom}; const $some = atom(0, '$some')`,
code: `const $some = atom(0, '$some')`,
options: [{ atomPrefix: '$' }],
},
{
code: `${ImportAtom}; const someAtom = atom(0, 'someAtom')`,
options: [{ atomPostfix: 'Atom' }],
code: `const someAtom = atom(0, '_someAtom')`,
options: [{ atomSuffix: 'Atom' }],
},
{
code: `function reatomSome() { const someAtom = atom(0, 'reatomSome.someAtom') }`,
de-jabber marked this conversation as resolved.
Show resolved Hide resolved
},
{
code: `const Atoms = { someAtom: atom(0, 'Atoms.someAtom') }`,
},
{
code: `function reatomSome() { const Atoms = { someAtom: atom(0, 'reatomSome.Atoms.someAtom') } }`,
de-jabber marked this conversation as resolved.
Show resolved Hide resolved
},
],
invalid: [
{
code: `${ImportAtom}; const some = atom(0)`,
errors: [{ messageId: 'nameMissing' }],
output: `${ImportAtom}; const some = atom(0, 'some')`,
code: `const some = atom(0)`,
errors: [{ message: /missing/ }],
output: `const some = atom(0, 'some')`,
},
{
code: `const some = atom(0, lololo)`,
errors: [{ message: /must be a correctly formatted string literal/ }],
output: `const some = atom(0, 'some')`,
},
{
code: `${ImportAtom}; const some = atom(0, 'unrelated')`,
errors: [{ messageId: 'nameIncorrect' }],
output: `${ImportAtom}; const some = atom(0, 'some')`,
code: `const some = atom(0, 'unrelated')`,
errors: [{ message: /name must be/ }],
output: `const some = atom(0, 'some')`,
},
{
code: `const some = atom(0, 'some')`,
options: [{ atomPrefix: '$' }],
errors: [{ message: /name must start with/ }],
output: `const $some = atom(0, '$some')`,
},
{
code: `${ImportAtom}; const some = atom(0, 'some')`,
options: [{ atomPostfix: 'Atom' }],
errors: [{ messageId: 'postfixMissing' }],
output: `${ImportAtom}; const someAtom = atom(0, 'someAtom')`,
code: `const some = atom(0, 'some')`,
options: [{ atomSuffix: 'Atom' }],
errors: [{ message: /name must end with/ }],
output: `const someAtom = atom(0, 'someAtom')`,
},
{
code: `${ImportAtom}; function reatomSome() { const field = atom(0, 'reatomSome._unrelated'); }`,
errors: [{ messageId: 'nameIncorrect' }],
output: `${ImportAtom}; function reatomSome() { const field = atom(0, 'reatomSome._field'); }`,
code: `function reatomSome() { const field = atom(0, 'reatomSome._unrelated'); }`,
errors: [{ message: /name must be/ }],
output: `function reatomSome() { const field = atom(0, 'reatomSome._field'); }`,
},
{
code: `${ImportAtom}; function reatomSome() { const field = atom(0, 'field') }`,
errors: [{ messageId: 'nameIncorrect' }],
output: `${ImportAtom}; function reatomSome() { const field = atom(0, 'reatomSome.field') }`,
code: `function reatomSome() { const field = atom(0, 'field') }`,
errors: [{ message: /domain must be/ }],
output: `function reatomSome() { const field = atom(0, 'reatomSome.field') }`,
},
{
code: `${ImportAtom}; function reatomSome() { const field = atom(0, 'Some.field') }`,
errors: [{ messageId: 'nameIncorrect' }],
output: `${ImportAtom}; function reatomSome() { const field = atom(0, 'reatomSome.field') }`,
code: `function reatomSome() { const field = atom(0, 'Some.field') }`,
errors: [{ message: /domain must be/ }],
output: `function reatomSome() { const field = atom(0, 'reatomSome.field') }`,
},
{
code: `${ImportAtom}; function reatomSome({name}) { const field = atom(0, 'field'); }`,
errors: [{ messageId: 'nameIncorrect' }],
output: `${ImportAtom}; function reatomSome({name}) { const field = atom(0, \`\${name}.field\`); }`,
code: `function reatomSome({name}) { const field = atom(0, 'field'); }`,
errors: [{ message: /domain must be set to the value of/ }],
output: `function reatomSome({name}) { const field = atom(0, \`\${name}.field\`); }`,
},
{
code: `${ImportAtom}; function reatomSome({name}) { const field = atom(0, 'Some.field'); }`,
errors: [{ messageId: 'nameIncorrect' }],
output: `${ImportAtom}; function reatomSome({name}) { const field = atom(0, \`\${name}.field\`); }`,
code: `function reatomSome({name}) { const field = atom(0, 'Some.field'); }`,
errors: [{ message: /domain must be set to the value/ }],
output: `function reatomSome({name}) { const field = atom(0, \`\${name}.field\`); }`,
de-jabber marked this conversation as resolved.
Show resolved Hide resolved
},
],
})
Loading