Skip to content

Commit 61e3a4f

Browse files
committed
Chore: add require-meta-docs-url rule
1 parent 6589d4a commit 61e3a4f

File tree

3 files changed

+261
-8
lines changed

3 files changed

+261
-8
lines changed

Diff for: .eslintrc.js

+17-2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
'use strict'
2+
3+
const version = require('./package.json').version
4+
15
module.exports = {
26
root: true,
37
parserOptions: {
@@ -15,8 +19,19 @@ module.exports = {
1519
'eslint-plugin'
1620
],
1721
rules: {
18-
'eslint-plugin/report-message-format': ['error', '^[A-Z].*\\.$'],
22+
'eslint-plugin/report-message-format': ['error', '^[A-Z`\'].*\\.$'],
1923
'eslint-plugin/prefer-placeholders': 'error',
2024
'eslint-plugin/consistent-output': 'error'
21-
}
25+
},
26+
27+
overrides: [{
28+
files: ['lib/rules/*.js'],
29+
rules: {
30+
"consistent-docs-description": "error",
31+
"no-invalid-meta": "error",
32+
"require-meta-docs-url": ["error", {
33+
"pattern": `https://github.com/vuejs/eslint-plugin-vue/blob/v${version}/docs/rules/{{name}}.md`
34+
}]
35+
}
36+
}]
2237
}

Diff for: eslint-internal-rules/require-meta-docs-url.js

+244
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
/**
2+
* @author Toru Nagashima <https://github.com/mysticatea>
3+
* @author Teddy Katz <https://github.com/not-an-aardvark>
4+
*
5+
* Three functions `isNormalFunctionExpression`, `getKeyName`, and `getRuleInfo`
6+
* are copied from https://github.com/not-an-aardvark/eslint-plugin-eslint-plugin/blob/master/lib/utils.js
7+
*
8+
* I have a plan to send this rule to that plugin: https://github.com/not-an-aardvark/eslint-plugin-eslint-plugin/issues/55
9+
*/
10+
11+
'use strict'
12+
13+
// -----------------------------------------------------------------------------
14+
// Requirements
15+
// -----------------------------------------------------------------------------
16+
17+
const path = require('path')
18+
19+
// -----------------------------------------------------------------------------
20+
// Helpers
21+
// -----------------------------------------------------------------------------
22+
23+
/**
24+
* Determines whether a node is a 'normal' (i.e. non-async, non-generator) function expression.
25+
* @param {ASTNode} node The node in question
26+
* @returns {boolean} `true` if the node is a normal function expression
27+
*/
28+
function isNormalFunctionExpression (node) {
29+
return (node.type === 'FunctionExpression' || node.type === 'ArrowFunctionExpression') && !node.generator && !node.async
30+
}
31+
32+
/**
33+
* Gets the key name of a Property, if it can be determined statically.
34+
* @param {ASTNode} node The `Property` node
35+
* @returns {string|null} The key name, or `null` if the name cannot be determined statically.
36+
*/
37+
function getKeyName (property) {
38+
if (!property.computed && property.key.type === 'Identifier') {
39+
return property.key.name
40+
}
41+
if (property.key.type === 'Literal') {
42+
return '' + property.key.value
43+
}
44+
if (property.key.type === 'TemplateLiteral' && property.key.quasis.length === 1) {
45+
return property.key.quasis[0].value.cooked
46+
}
47+
return null
48+
}
49+
50+
/**
51+
* Performs static analysis on an AST to try to determine the final value of `module.exports`.
52+
* @param {ASTNode} ast The `Program` AST node
53+
* @returns {Object} An object with keys `meta`, `create`, and `isNewStyle`. `meta` and `create` correspond to the AST nodes
54+
for the final values of `module.exports.meta` and `module.exports.create`. `isNewStyle` will be `true` if `module.exports`
55+
is an object, and `false` if module.exports is just the `create` function. If no valid ESLint rule info can be extracted
56+
from the file, the return value will be `null`.
57+
*/
58+
function getRuleInfo (ast) {
59+
const INTERESTING_KEYS = new Set(['create', 'meta'])
60+
let exportsVarOverridden = false
61+
let exportsIsFunction = false
62+
63+
const exportNodes = ast.body
64+
.filter(statement => statement.type === 'ExpressionStatement')
65+
.map(statement => statement.expression)
66+
.filter(expression => expression.type === 'AssignmentExpression')
67+
.filter(expression => expression.left.type === 'MemberExpression')
68+
.reduce((currentExports, node) => {
69+
if (
70+
node.left.object.type === 'Identifier' && node.left.object.name === 'module' &&
71+
node.left.property.type === 'Identifier' && node.left.property.name === 'exports'
72+
) {
73+
exportsVarOverridden = true
74+
75+
if (isNormalFunctionExpression(node.right)) {
76+
// Check `module.exports = function () {}`
77+
78+
exportsIsFunction = true
79+
return { create: node.right, meta: null }
80+
} else if (node.right.type === 'ObjectExpression') {
81+
// Check `module.exports = { create: function () {}, meta: {} }`
82+
83+
exportsIsFunction = false
84+
return node.right.properties.reduce((parsedProps, prop) => {
85+
const keyValue = getKeyName(prop)
86+
if (INTERESTING_KEYS.has(keyValue)) {
87+
parsedProps[keyValue] = prop.value
88+
}
89+
return parsedProps
90+
}, {})
91+
}
92+
return {}
93+
} else if (
94+
!exportsIsFunction &&
95+
node.left.object.type === 'MemberExpression' &&
96+
node.left.object.object.type === 'Identifier' && node.left.object.object.name === 'module' &&
97+
node.left.object.property.type === 'Identifier' && node.left.object.property.name === 'exports' &&
98+
node.left.property.type === 'Identifier' && INTERESTING_KEYS.has(node.left.property.name)
99+
) {
100+
// Check `module.exports.create = () => {}`
101+
102+
currentExports[node.left.property.name] = node.right
103+
} else if (
104+
!exportsVarOverridden &&
105+
node.left.object.type === 'Identifier' && node.left.object.name === 'exports' &&
106+
node.left.property.type === 'Identifier' && INTERESTING_KEYS.has(node.left.property.name)
107+
) {
108+
// Check `exports.create = () => {}`
109+
110+
currentExports[node.left.property.name] = node.right
111+
}
112+
return currentExports
113+
}, {})
114+
115+
return Object.prototype.hasOwnProperty.call(exportNodes, 'create') && isNormalFunctionExpression(exportNodes.create)
116+
? Object.assign({ isNewStyle: !exportsIsFunction, meta: null }, exportNodes)
117+
: null
118+
}
119+
120+
// -----------------------------------------------------------------------------
121+
// Rule Definition
122+
// -----------------------------------------------------------------------------
123+
124+
module.exports = {
125+
meta: {
126+
docs: {
127+
description: 'require rules to implement a meta.docs.url property',
128+
category: 'Rules',
129+
recommended: false
130+
},
131+
fixable: 'code',
132+
schema: [{
133+
type: 'object',
134+
properties: {
135+
pattern: { type: 'string' }
136+
},
137+
additionalProperties: false
138+
}]
139+
},
140+
141+
/**
142+
* Creates AST event handlers for require-meta-docs-url.
143+
* @param {RuleContext} context - The rule context.
144+
* @returns {Object} AST event handlers.
145+
*/
146+
create (context) {
147+
const options = context.options[0] || {}
148+
const sourceCode = context.getSourceCode()
149+
const filename = context.getFilename()
150+
const ruleName = filename === '<input>' ? undefined : path.basename(filename, '.js')
151+
const expectedUrl = !options.pattern || !ruleName
152+
? undefined
153+
: options.pattern.replace(/{{\s*name\s*}}/g, ruleName)
154+
155+
/**
156+
* Check whether a given node is the expected URL.
157+
* @param {Node} node The node of property value to check.
158+
* @returns {boolean} `true` if the node is the expected URL.
159+
*/
160+
function isExpectedUrl (node) {
161+
return Boolean(
162+
node &&
163+
node.type === 'Literal' &&
164+
typeof node.value === 'string' &&
165+
(
166+
expectedUrl === undefined ||
167+
node.value === expectedUrl
168+
)
169+
)
170+
}
171+
172+
/**
173+
* Insert a given property into a given object literal.
174+
* @param {SourceCodeFixer} fixer The fixer.
175+
* @param {Node} node The ObjectExpression node to insert a property.
176+
* @param {string} propertyText The property code to insert.
177+
* @returns {void}
178+
*/
179+
function insertProperty (fixer, node, propertyText) {
180+
if (node.properties.length === 0) {
181+
return fixer.replaceText(node, `{\n${propertyText}\n}`)
182+
}
183+
return fixer.insertTextAfter(
184+
sourceCode.getLastToken(node.properties[node.properties.length - 1]),
185+
`,\n${propertyText}`
186+
)
187+
}
188+
189+
return {
190+
Program (node) {
191+
const info = getRuleInfo(node)
192+
if (!info) {
193+
return
194+
}
195+
const metaNode = info.meta
196+
const docsPropNode =
197+
metaNode &&
198+
metaNode.properties &&
199+
metaNode.properties.find(p => p.type === 'Property' && getKeyName(p) === 'docs')
200+
const urlPropNode =
201+
docsPropNode &&
202+
docsPropNode.value.properties &&
203+
docsPropNode.value.properties.find(p => p.type === 'Property' && getKeyName(p) === 'url')
204+
205+
if (isExpectedUrl(urlPropNode && urlPropNode.value)) {
206+
return
207+
}
208+
209+
context.report({
210+
loc:
211+
(urlPropNode && urlPropNode.value.loc) ||
212+
(docsPropNode && docsPropNode.value.loc) ||
213+
(metaNode && metaNode.loc) ||
214+
node.loc.start,
215+
216+
message:
217+
!urlPropNode ? 'Rules should export a `meta.docs.url` property.'
218+
: !expectedUrl ? '`meta.docs.url` property must be a string.'
219+
/* otherwise */ : '`meta.docs.url` property must be `{{expectedUrl}}`.',
220+
221+
data: {
222+
expectedUrl
223+
},
224+
225+
fix (fixer) {
226+
if (expectedUrl) {
227+
const urlString = JSON.stringify(expectedUrl)
228+
if (urlPropNode) {
229+
return fixer.replaceText(urlPropNode.value, urlString)
230+
}
231+
if (docsPropNode && docsPropNode.value.type === 'ObjectExpression') {
232+
return insertProperty(fixer, docsPropNode.value, `url: ${urlString}`)
233+
}
234+
if (!docsPropNode && metaNode && metaNode.type === 'ObjectExpression') {
235+
return insertProperty(fixer, metaNode, `docs: {\nurl: ${urlString}\n}`)
236+
}
237+
}
238+
return null
239+
}
240+
})
241+
}
242+
}
243+
}
244+
}

Diff for: lib/rules/.eslintrc.json

-6
This file was deleted.

0 commit comments

Comments
 (0)