Skip to content

Commit 19d72ac

Browse files
committed
feat(workspace-plugin): add eslint-rule generator
1 parent ff13b22 commit 19d72ac

8 files changed

Lines changed: 185 additions & 5 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@
9999
"@nx/plugin": "17.2.8",
100100
"@nx/workspace": "17.2.8",
101101
"@octokit/rest": "18.12.0",
102-
"@phenomnomnominal/tsquery": "6.1.2",
102+
"@phenomnomnominal/tsquery": "6.1.3",
103103
"@storybook/addon-a11y": "6.5.15",
104104
"@storybook/addon-actions": "6.5.15",
105105
"@storybook/addon-docs": "6.5.15",

tools/workspace-plugin/generators.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,11 @@
7979
"implementation": "./src/generators/workspace-generator/index.ts",
8080
"schema": "./src/generators/workspace-generator/schema.json",
8181
"description": "Generator workspace-generator"
82+
},
83+
"eslint-rule": {
84+
"factory": "./src/generators/eslint-rule/generator",
85+
"schema": "./src/generators/eslint-rule/schema.json",
86+
"description": "eslint-rule generator"
8287
}
8388
}
8489
}

tools/workspace-plugin/src/generators/eslint-rule/files/.gitkeep

Whitespace-only changes.
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
2+
import { Tree, readProjectConfiguration, joinPathFragments } from '@nx/devkit';
3+
import { lintWorkspaceRulesProjectGenerator } from '@nx/eslint/src/generators/workspace-rules-project/workspace-rules-project';
4+
5+
import { eslintRuleGenerator } from './generator';
6+
import { EslintRuleGeneratorSchema } from './schema';
7+
8+
describe('eslint-rule generator', () => {
9+
// eslint-disable-next-line @typescript-eslint/no-empty-function
10+
const noop = () => {};
11+
let tree: Tree;
12+
const options: EslintRuleGeneratorSchema = { name: 'uppercase' };
13+
14+
beforeEach(async () => {
15+
jest.spyOn(console, 'info').mockImplementation(noop);
16+
tree = createTreeWithEmptyWorkspace();
17+
await lintWorkspaceRulesProjectGenerator(tree, {});
18+
});
19+
20+
it('should generate new eslint rule', async () => {
21+
const config = readProjectConfiguration(tree, 'eslint-rules');
22+
const paths = {
23+
impl: joinPathFragments(config.root, 'rules/uppercase.ts'),
24+
spec: joinPathFragments(config.root, 'rules/uppercase.spec.ts'),
25+
};
26+
27+
await eslintRuleGenerator(tree, options);
28+
29+
expect(tree.read(paths.impl, 'utf-8')).toMatchInlineSnapshot(`
30+
"/**
31+
* This file sets you up with structure needed for an ESLint rule.
32+
*
33+
* It leverages utilities from @typescript-eslint to allow TypeScript to
34+
* provide autocompletions etc for the configuration.
35+
*
36+
* Your rule's custom logic will live within the create() method below
37+
* and you can learn more about writing ESLint rules on the official guide:
38+
*
39+
* https://eslint.org/docs/developer-guide/working-with-rules
40+
*
41+
* You can also view many examples of existing rules here:
42+
*
43+
* https://github.com/typescript-eslint/typescript-eslint/tree/master/packages/eslint-plugin/src/rules
44+
*/
45+
import { ESLintUtils } from '@typescript-eslint/experimental-utils';
46+
// NOTE: The rule will be available in ESLint configs as \\"@nx/workspace-uppercase\\"
47+
export const RULE_NAME = 'uppercase';
48+
export const rule = ESLintUtils.RuleCreator(() => __filename)({
49+
name: RULE_NAME,
50+
meta: {
51+
type: 'problem',
52+
docs: {
53+
category: 'Best Practices',
54+
description: \`\`,
55+
recommended: 'error',
56+
},
57+
schema: [],
58+
messages: {},
59+
},
60+
defaultOptions: [],
61+
create(context) {
62+
return {};
63+
},
64+
});
65+
"
66+
`);
67+
68+
expect(tree.read(paths.spec, 'utf-8')).toMatchInlineSnapshot(`
69+
"import { TSESLint } from '@typescript-eslint/experimental-utils';
70+
import { rule, RULE_NAME } from './uppercase';
71+
const ruleTester = new TSESLint.RuleTester({
72+
parser: require.resolve('@typescript-eslint/parser'),
73+
});
74+
ruleTester.run(RULE_NAME, rule, {
75+
valid: [\`const example = true;\`],
76+
invalid: [],
77+
});
78+
"
79+
`);
80+
});
81+
});
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { formatFiles, joinPathFragments, ProjectConfiguration, readProjectConfiguration, Tree } from '@nx/devkit';
2+
3+
import * as tsquery from '@phenomnomnominal/tsquery';
4+
import { EslintRuleGeneratorSchema } from './schema';
5+
import { lintWorkspaceRuleGenerator } from '@nx/eslint/src/generators/workspace-rule/workspace-rule';
6+
7+
interface Options extends ReturnType<typeof normalizeSchema> {}
8+
9+
export async function eslintRuleGenerator(tree: Tree, schema: EslintRuleGeneratorSchema) {
10+
const options = normalizeSchema(schema);
11+
await lintWorkspaceRuleGenerator(tree, options);
12+
13+
const project = readProjectConfiguration(tree, 'eslint-rules');
14+
replaceBoilerplateToUseOldTypescriptEslintSyntax(tree, { project, ...options });
15+
16+
await formatFiles(tree);
17+
}
18+
19+
export default eslintRuleGenerator;
20+
21+
function normalizeSchema(schema: EslintRuleGeneratorSchema) {
22+
return { ...schema, directory: schema.directory ?? 'rules' } as Required<EslintRuleGeneratorSchema>;
23+
}
24+
25+
// TODO: remove this after we migrate to @typescript-eslint v5 which exposes utils package instead of experimental-utils
26+
function replaceBoilerplateToUseOldTypescriptEslintSyntax(
27+
tree: Tree,
28+
options: Options & { project: ProjectConfiguration },
29+
) {
30+
const ruleDir = joinPathFragments(options.project.root, options.directory);
31+
const paths = {
32+
impl: joinPathFragments(ruleDir, `${options.name}.ts`),
33+
spec: joinPathFragments(ruleDir, `${options.name}.spec.ts`),
34+
};
35+
36+
const implContent = tree.read(paths.impl, 'utf-8') ?? '';
37+
const specContent = tree.read(paths.spec, 'utf-8') ?? '';
38+
39+
const defaultImport = '@typescript-eslint/utils';
40+
const ourImport = '@typescript-eslint/experimental-utils';
41+
const IMPORT_AST_SELECTOR = `ImportDeclaration StringLiteral[value=${defaultImport}]`;
42+
43+
const updatedSpecContent = tsquery.replace(specContent, IMPORT_AST_SELECTOR, () => {
44+
return `'${ourImport}'`;
45+
});
46+
47+
let updatedImplContent = tsquery.replace(implContent, IMPORT_AST_SELECTOR, () => {
48+
return `'${ourImport}'`;
49+
});
50+
updatedImplContent = tsquery.replace(
51+
updatedImplContent,
52+
'ObjectLiteralExpression PropertyAssignment Identifier[name=docs] ~ ObjectLiteralExpression',
53+
() => {
54+
return `{
55+
category: 'Best Practices',
56+
description: \`\`,
57+
recommended: 'error',
58+
}`;
59+
},
60+
);
61+
62+
tree.write(paths.impl, updatedImplContent);
63+
tree.write(paths.spec, updatedSpecContent);
64+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export interface EslintRuleGeneratorSchema {
2+
name: string;
3+
/**
4+
* @default 'rules'
5+
*/
6+
directory?: string;
7+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"$schema": "http://json-schema.org/schema",
3+
"$id": "EslintRule",
4+
"title": "",
5+
"type": "object",
6+
"properties": {
7+
"name": {
8+
"type": "string",
9+
"description": "The name of the new rule.",
10+
"$default": {
11+
"$source": "argv",
12+
"index": 0
13+
}
14+
},
15+
"directory": {
16+
"type": "string",
17+
"description": "Create the rule under this directory within `tools/eslint-rules/` (can be nested).",
18+
"alias": "dir",
19+
"default": "rules"
20+
}
21+
},
22+
"required": ["name", "directory"]
23+
}

yarn.lock

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3383,10 +3383,10 @@
33833383
resolved "https://registry.yarnpkg.com/@opentelemetry/context-base/-/context-base-0.6.1.tgz#b260e454ee4f9635ea024fc83be225e397f15363"
33843384
integrity sha512-5bHhlTBBq82ti3qPT15TRxkYTFPPQWbnkkQkmHPtqiS1XcTB69cEKd3Jm7Cfi/vkPoyxapmePE9tyA7EzLt8SQ==
33853385

3386-
"@phenomnomnominal/[email protected].2":
3387-
version "6.1.2"
3388-
resolved "https://registry.yarnpkg.com/@phenomnomnominal/tsquery/-/tsquery-6.1.2.tgz#37ec13373ec144f524958770ebc294d0b5e2909e"
3389-
integrity sha512-NahxUvas4D4iRV1NqlL6Z3mIl2Fo+rw1x77wgZpYyaQjQnS4svv6XoVzjcRRtnP5cfY6XuVKLZki8Zltkz8z0w==
3386+
"@phenomnomnominal/[email protected].3":
3387+
version "6.1.3"
3388+
resolved "https://registry.yarnpkg.com/@phenomnomnominal/tsquery/-/tsquery-6.1.3.tgz#5e819403da2fa6a64b009f1876278fb105ec6b55"
3389+
integrity sha512-CEqpJ872StsxRmwv9ePCZ4BCisrJSlREUC5XxIRYxhvODt4aQoJFFmjTgaP6meyKiiXxxN/VWPZ58j4yHXRkmw==
33903390
dependencies:
33913391
"@types/esquery" "^1.5.0"
33923392
esquery "^1.5.0"

0 commit comments

Comments
 (0)