@lewishowles/lint-config 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,275 @@
1
+ import { addTerminalPunctuation, capitaliseSentence, formatSentence } from "../utils/wrap.js";
2
+ import { formatJSDocPunctuation, isJSDoc } from "../utils/jsdoc.js";
3
+ import {
4
+ getCommentText,
5
+ getLineCommentGroups,
6
+ getLineIndent,
7
+ getNewline,
8
+ replaceMinimalComment,
9
+ } from "../utils/source.js";
10
+
11
+ /**
12
+ * Replace only the value of a line comment.
13
+ *
14
+ * @param {object} sourceCode
15
+ * The Oxlint source code object.
16
+ * @param {object} comment
17
+ * The line comment token.
18
+ * @param {string} value
19
+ * The replacement comment value.
20
+ *
21
+ * @returns {string}
22
+ * The replacement comment text.
23
+ */
24
+ function replaceLineCommentValue(sourceCode, comment, value) {
25
+ // The comment's raw source text.
26
+ const commentText = getCommentText(sourceCode, comment);
27
+
28
+ return `${commentText.slice(0, 2)}${value}`;
29
+ }
30
+
31
+ /**
32
+ * Format the prose text in a standard block-comment line.
33
+ *
34
+ * @param {string} line
35
+ * The block-comment line.
36
+ * @param {function} formatProse
37
+ * The formatter for the line's prose.
38
+ *
39
+ * @returns {string}
40
+ * The formatted block-comment line.
41
+ */
42
+ function formatBlockCommentLine(line, formatProse) {
43
+ // The line's leading `*` decoration, when present.
44
+ const marker = line.match(/^\s*\*\s?/);
45
+
46
+ if (marker === null) {
47
+ return line;
48
+ }
49
+
50
+ return `${marker[0]}${formatProse(line.slice(marker[0].length).trim())}`;
51
+ }
52
+
53
+ /**
54
+ * Return a sentence-formatted line-comment group.
55
+ *
56
+ * @param {object} sourceCode
57
+ * The Oxlint source code object.
58
+ * @param {object[]} comments
59
+ * The adjacent line comments.
60
+ *
61
+ * @returns {object|null}
62
+ * The first and last replacements, or null when no sentence needs work.
63
+ */
64
+ function formatLineCommentGroup(sourceCode, comments) {
65
+ // The group's first comment token.
66
+ const firstComment = comments[0];
67
+ // The group's last comment token.
68
+ const lastComment = comments.at(-1);
69
+ // The first comment's undecorated text.
70
+ const firstText = firstComment.value.trim();
71
+
72
+ if (firstText === "" || firstText.startsWith("@")) {
73
+ return null;
74
+ }
75
+
76
+ // The first comment's value, capitalised or fully sentence-formatted.
77
+ const firstValue =
78
+ comments.length === 1
79
+ ? formatSentence(firstComment.value)
80
+ : capitaliseSentence(firstComment.value);
81
+
82
+ // The last comment's value, punctuated to close the sentence.
83
+ const lastValue = comments.length === 1 ? firstValue : addTerminalPunctuation(lastComment.value);
84
+ // The replacement comment text for the first and last comments.
85
+ const firstReplacement = replaceLineCommentValue(sourceCode, firstComment, firstValue);
86
+ // The replacement comment text for the last comment.
87
+ const lastReplacement = replaceLineCommentValue(sourceCode, lastComment, lastValue);
88
+
89
+ if (
90
+ firstReplacement === getCommentText(sourceCode, firstComment) &&
91
+ lastReplacement === getCommentText(sourceCode, lastComment)
92
+ ) {
93
+ return null;
94
+ }
95
+
96
+ return { firstComment, firstReplacement, lastComment, lastReplacement };
97
+ }
98
+
99
+ /**
100
+ * Format prose in an ordinary block comment.
101
+ *
102
+ * @param {object} sourceCode
103
+ * The Oxlint source code object.
104
+ * @param {object} comment
105
+ * The block comment token.
106
+ *
107
+ * @returns {string}
108
+ * The sentence-formatted comment text.
109
+ */
110
+ function formatOrdinaryBlockComment(sourceCode, comment) {
111
+ // The comment's raw source text.
112
+ const commentText = getCommentText(sourceCode, comment);
113
+ // The indentation the comment's lines are aligned to.
114
+ const indentation = getLineIndent(sourceCode, comment.range[0]);
115
+ // The comment body, stripped of its /* */ delimiters.
116
+ const content = commentText.slice(2, -2).trim();
117
+
118
+ if (indentation === null || content === "" || /^(?:eslint|oxlint|istanbul|c8)-/.test(content)) {
119
+ return commentText;
120
+ }
121
+
122
+ if (!commentText.includes("\n") && !commentText.includes("\r")) {
123
+ return `/* ${formatSentence(content)} */`;
124
+ }
125
+
126
+ // The comment's individual source lines.
127
+ const lines = commentText.split(/\r\n|\n|\r/);
128
+
129
+ // The indexes of lines carrying prose, excluding the delimiter lines.
130
+ const proseLineIndexes = lines
131
+ .slice(1, -1)
132
+ .map((line, index) => ({ index: index + 1, text: line.replace(/^\s*\*?\s?/, "").trim() }))
133
+ .filter((line) => line.text !== "")
134
+ .map((line) => line.index);
135
+
136
+ if (lines[0] === "/*" && lines.at(-1).trim() === "*/" && proseLineIndexes.length > 0) {
137
+ // The comment lines, formatted in place.
138
+ const formattedLines = [...lines];
139
+ // The first and last prose line indexes, which start and end the sentence.
140
+ const firstProseLine = proseLineIndexes[0];
141
+ // The last prose line index, which ends the sentence.
142
+ const lastProseLine = proseLineIndexes.at(-1);
143
+
144
+ formattedLines[firstProseLine] = formatBlockCommentLine(
145
+ formattedLines[firstProseLine],
146
+ capitaliseSentence,
147
+ );
148
+ formattedLines[lastProseLine] = formatBlockCommentLine(
149
+ formattedLines[lastProseLine],
150
+ addTerminalPunctuation,
151
+ );
152
+
153
+ return formattedLines.join(getNewline(sourceCode.text));
154
+ }
155
+
156
+ // The comment's prose, joined into a single paragraph.
157
+ const paragraphs = content
158
+ .split(/\r\n|\n|\r/)
159
+ .map((line) => line.replace(/^\s*\*?\s?/, "").trim())
160
+ .filter(Boolean)
161
+ .join(" ");
162
+
163
+ return ["/*", `${indentation} * ${formatSentence(paragraphs)}`, `${indentation} */`].join(
164
+ getNewline(sourceCode.text),
165
+ );
166
+ }
167
+
168
+ /**
169
+ * Create the complete-sentence comment rule.
170
+ *
171
+ * @returns {object}
172
+ * The Oxlint rule definition.
173
+ */
174
+ export default {
175
+ meta: {
176
+ docs: { description: "Format existing comments as complete sentences." },
177
+ fixable: "code",
178
+ type: "layout",
179
+ },
180
+ /**
181
+ * Create the rule's node visitors.
182
+ *
183
+ * @param {object} context
184
+ * The Oxlint rule context.
185
+ *
186
+ * @returns {object}
187
+ * The visitor functions for this rule.
188
+ */
189
+ createOnce(context) {
190
+ return {
191
+ /**
192
+ * Format every comment in the file as a complete sentence.
193
+ */
194
+ Program() {
195
+ for (const commentGroup of getLineCommentGroups(context.sourceCode)) {
196
+ // The group's replacement text, or null when it already reads as a sentence.
197
+ const formattedGroup = formatLineCommentGroup(context.sourceCode, commentGroup);
198
+
199
+ if (formattedGroup) {
200
+ context.report({
201
+ /**
202
+ * Apply the formatted replacement to the comment group.
203
+ *
204
+ * @param {object} fixer
205
+ * The Oxlint fixer.
206
+ *
207
+ * @returns {object[]}
208
+ * The fixes to apply.
209
+ */
210
+ fix: (fixer) => {
211
+ // The fixes to apply, starting with the first comment's replacement.
212
+ const fixes = [
213
+ replaceMinimalComment(
214
+ fixer,
215
+ formattedGroup.firstComment,
216
+ getCommentText(context.sourceCode, formattedGroup.firstComment),
217
+ formattedGroup.firstReplacement,
218
+ ),
219
+ ];
220
+
221
+ if (formattedGroup.lastComment.range[0] !== formattedGroup.firstComment.range[0]) {
222
+ fixes.push(
223
+ replaceMinimalComment(
224
+ fixer,
225
+ formattedGroup.lastComment,
226
+ getCommentText(context.sourceCode, formattedGroup.lastComment),
227
+ formattedGroup.lastReplacement,
228
+ ),
229
+ );
230
+ }
231
+
232
+ return fixes;
233
+ },
234
+ message: "Comment text must be a complete sentence.",
235
+ node: formattedGroup.firstComment,
236
+ });
237
+ }
238
+ }
239
+
240
+ for (const comment of context.sourceCode.getAllComments()) {
241
+ if (comment.type !== "Block") {
242
+ continue;
243
+ }
244
+
245
+ // The comment's raw source text.
246
+ const commentText = getCommentText(context.sourceCode, comment);
247
+
248
+ // The comment, sentence-formatted using the JSDoc or ordinary-block formatter.
249
+ const formattedComment = isJSDoc(commentText)
250
+ ? formatJSDocPunctuation(context.sourceCode, comment)
251
+ : formatOrdinaryBlockComment(context.sourceCode, comment);
252
+
253
+ if (formattedComment !== commentText) {
254
+ context.report({
255
+ /**
256
+ * Apply the sentence-formatted replacement to the comment.
257
+ *
258
+ * @param {object} fixer
259
+ * The Oxlint fixer.
260
+ *
261
+ * @returns {object}
262
+ * The fix to apply.
263
+ */
264
+ fix: (fixer) => {
265
+ return replaceMinimalComment(fixer, comment, commentText, formattedComment);
266
+ },
267
+ message: "Comment text must be a complete sentence.",
268
+ node: comment,
269
+ });
270
+ }
271
+ }
272
+ },
273
+ };
274
+ },
275
+ };
@@ -0,0 +1,71 @@
1
+ import { hasImmediateLineComment } from "../utils/source.js";
2
+
3
+ /**
4
+ * Return whether a declaration is inside a loop header.
5
+ *
6
+ * @param {object} node
7
+ * The variable declaration node.
8
+ *
9
+ * @returns {boolean}
10
+ * Whether the declaration is exempt from the rule.
11
+ */
12
+ function isLoopHeaderDeclaration(node) {
13
+ // Inspects the declaration's parent to identify loop headers.
14
+ const parent = node.parent;
15
+
16
+ return (
17
+ (parent.type === "ForStatement" && parent.init === node) ||
18
+ ((parent.type === "ForInStatement" || parent.type === "ForOfStatement") && parent.left === node)
19
+ );
20
+ }
21
+
22
+ /**
23
+ * Create the variable-declaration comment rule.
24
+ *
25
+ * @returns {object}
26
+ * The Oxlint rule definition.
27
+ */
28
+ export default {
29
+ meta: {
30
+ docs: { description: "Require comments before variable declarations." },
31
+ type: "suggestion",
32
+ },
33
+ /**
34
+ * Create the rule's node visitors.
35
+ *
36
+ * @param {object} context
37
+ * The Oxlint rule context.
38
+ *
39
+ * @returns {object}
40
+ * The visitor functions for this rule.
41
+ */
42
+ createOnce(context) {
43
+ return {
44
+ /**
45
+ * Check a const or let declaration for a preceding comment.
46
+ *
47
+ * @param {object} node
48
+ * The variable declaration node.
49
+ */
50
+ VariableDeclaration(node) {
51
+ if ((node.kind !== "const" && node.kind !== "let") || isLoopHeaderDeclaration(node)) {
52
+ return;
53
+ }
54
+
55
+ if (!hasImmediateLineComment(context.sourceCode, node)) {
56
+ context.report({
57
+ message: "Variable declarations require an immediately preceding line comment.",
58
+ node,
59
+ });
60
+ }
61
+
62
+ if (node.declarations.length > 1) {
63
+ context.report({
64
+ message: "Declare one variable per declaration statement.",
65
+ node,
66
+ });
67
+ }
68
+ },
69
+ };
70
+ },
71
+ };
@@ -0,0 +1,169 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { isDirectiveComment } from "../utils/source.js";
3
+
4
+ // Captures the opening tag's attributes separately, so the setup attribute
5
+ // can be tested without the tag being available from Oxlint's extracted AST.
6
+ const scriptBlockPattern =
7
+ /(?<openingTag><script\b(?<attributes>[^>]*)>)(?<content>[\s\S]*?)<\/script\s*>/gi;
8
+
9
+ // Only a standalone setup attribute qualifies, avoiding matches such as
10
+ // data-setup or setup-mode.
11
+ const setupAttributePattern = /(?:^|\s)setup(?:\s|=|$)/i;
12
+ // Match only indentation and one line break, so documentation sits directly
13
+ // after the script tag.
14
+ const immediateCommentGapPattern = /^[ \t]*(?:\r?\n[ \t]*)?$/;
15
+
16
+ /**
17
+ * Read raw Vue source because extracted script text cannot identify script
18
+ * setup.
19
+ *
20
+ * @param {object} context
21
+ * The Oxlint rule context.
22
+ *
23
+ * @returns {string|null}
24
+ * The Vue source, when the physical filename can be read.
25
+ */
26
+ function getVueSource(context) {
27
+ try {
28
+ // The extracted script text does not include the component markup.
29
+ // Read the opening tag from the Vue file instead.
30
+ const physicalFilename = context.physicalFilename;
31
+
32
+ if (typeof physicalFilename !== "string" || !physicalFilename.endsWith(".vue")) {
33
+ return null;
34
+ }
35
+
36
+ if (!existsSync(physicalFilename)) {
37
+ return null;
38
+ }
39
+
40
+ return readFileSync(physicalFilename, "utf8");
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Get raw Vue script blocks in source order.
48
+ *
49
+ * @param {object} context
50
+ * The Oxlint rule context.
51
+ *
52
+ * @returns {RegExpMatchArray[]}
53
+ * The Vue script blocks, or an empty array when the source is unavailable.
54
+ */
55
+ function getScriptBlocks(context) {
56
+ // Component markup is only available from the physical Vue file.
57
+ const vueSource = getVueSource(context);
58
+
59
+ return vueSource ? Array.from(vueSource.matchAll(scriptBlockPattern)) : [];
60
+ }
61
+
62
+ /**
63
+ * Find the raw script block matching the current Program's extracted text.
64
+ *
65
+ * @param {object} context
66
+ * The rule context for the extracted script block.
67
+ *
68
+ * @returns {RegExpMatchArray|undefined}
69
+ * The matching raw script block, when one is found.
70
+ */
71
+ function findScriptBlock(context) {
72
+ // Re-read on every call rather than caching, since this file's other
73
+ // script blocks may not have been visited yet or ever in this run.
74
+ const scriptBlocks = getScriptBlocks(context);
75
+
76
+ return scriptBlocks.find(
77
+ (scriptBlock) => scriptBlock.groups.content.trim() === context.sourceCode.text.trim(),
78
+ );
79
+ }
80
+
81
+ /**
82
+ * Check whether a script block uses Vue's setup attribute.
83
+ *
84
+ * @param {RegExpMatchArray} scriptBlock
85
+ * The raw Vue script block.
86
+ *
87
+ * @returns {boolean}
88
+ * Whether the script block is a script setup block.
89
+ */
90
+ function isScriptSetupBlock(scriptBlock) {
91
+ // Inspect only the opening tag, so setup code cannot affect the block type.
92
+ const scriptAttributes = scriptBlock.groups?.attributes ?? "";
93
+
94
+ return setupAttributePattern.test(scriptAttributes);
95
+ }
96
+
97
+ /**
98
+ * Check whether a Vue script setup block starts with a documentation comment.
99
+ *
100
+ * @param {object} context
101
+ * The rule context for the extracted script block.
102
+ * @param {RegExpMatchArray|undefined} scriptBlock
103
+ * The script block matched to the current component's entry point.
104
+ *
105
+ * @returns {boolean}
106
+ * Whether the component has the required documentation comment.
107
+ */
108
+ function hasComponentDocumentation(context, scriptBlock) {
109
+ if (!scriptBlock || !isScriptSetupBlock(scriptBlock)) {
110
+ return true;
111
+ }
112
+
113
+ // This Program visitor runs once per script block, so sourceCode is scoped
114
+ // to the current block and [0] cannot pick up a comment from another one.
115
+ const comment = context.sourceCode.getAllComments()[0];
116
+
117
+ // Lint directives alter rule execution but cannot document a component.
118
+ if (comment?.type !== "Block" || isDirectiveComment(comment)) {
119
+ return false;
120
+ }
121
+
122
+ // A blank line would separate the component documentation from its entry point.
123
+ const commentGap = context.sourceCode.text.slice(0, comment.range[0]);
124
+
125
+ return immediateCommentGapPattern.test(commentGap);
126
+ }
127
+
128
+ export default {
129
+ meta: {
130
+ docs: { description: "Require documentation for Vue script setup components." },
131
+ type: "suggestion",
132
+ },
133
+ /**
134
+ * Create the checks that inspect each script block.
135
+ *
136
+ * @param {object} context
137
+ * The Oxlint rule context.
138
+ *
139
+ * @returns {object}
140
+ * The script-block checks for this rule.
141
+ */
142
+ createOnce(context) {
143
+ return {
144
+ /**
145
+ * Check the current component's script setup block for documentation.
146
+ *
147
+ * @param {object} node
148
+ * The entry point parsed from the current script block.
149
+ */
150
+ Program(node) {
151
+ // createOnce builds this visitor once for the whole run, and a
152
+ // single file's script blocks are not necessarily visited
153
+ // consecutively, so the matching block is looked up fresh on each
154
+ // call rather than tracked with shared state.
155
+ const scriptBlock = findScriptBlock(context);
156
+
157
+ if (hasComponentDocumentation(context, scriptBlock)) {
158
+ return;
159
+ }
160
+
161
+ context.report({
162
+ message:
163
+ "Vue script setup components require a documentation block after the opening tag.",
164
+ node: node.body[0] ?? node,
165
+ });
166
+ },
167
+ };
168
+ },
169
+ };
@@ -0,0 +1,123 @@
1
+ import { getCommentNeighbours, isDirectiveComment, isLeadingComment } from "../utils/source.js";
2
+ import { getObjectArgument, getObjectProperties, isNamedCall } from "../utils/vue-macro.js";
3
+
4
+ // Matches the single newline and indentation allowed between a comment and an
5
+ // event.
6
+ const immediateCommentGapPattern = /^\r?\n[ \t]*$/;
7
+
8
+ // The message shared by all undocumented runtime events.
9
+ const missingCommentMessage =
10
+ "Vue emit declarations require an immediately preceding block comment.";
11
+
12
+ /**
13
+ * Return whether the current lint target is a physical Vue single-file
14
+ * component.
15
+ *
16
+ * @param {object} context
17
+ * The Oxlint rule context.
18
+ *
19
+ * @returns {boolean}
20
+ * Whether the current file has a Vue filename.
21
+ */
22
+ function isVueFile(context) {
23
+ try {
24
+ // Use the physical path because the AST does not identify Vue files.
25
+ const physicalFilename = context.physicalFilename;
26
+
27
+ return typeof physicalFilename === "string" && physicalFilename.endsWith(".vue");
28
+ } catch {
29
+ return false;
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Return whether a property has an immediately preceding block comment.
35
+ *
36
+ * @param {object} sourceCode
37
+ * The Oxlint source code object.
38
+ * @param {object} property
39
+ * The property to inspect.
40
+ *
41
+ * @returns {boolean}
42
+ * Whether the property has the required documentation comment.
43
+ */
44
+ function hasBlockComment(sourceCode, property) {
45
+ // Finds the closest preceding comment.
46
+ const comment = sourceCode
47
+ .getAllComments()
48
+ .findLast((candidate) => candidate.range[1] <= property.range[0]);
49
+
50
+ if (comment?.type !== "Block" || isDirectiveComment(comment)) {
51
+ return false;
52
+ }
53
+
54
+ // Checks the comments immediately around the property.
55
+ const { next, previous } = getCommentNeighbours(sourceCode, comment);
56
+ // The source text between the comment and the property.
57
+ const gap = sourceCode.text.slice(comment.range[1], property.range[0]);
58
+
59
+ return (
60
+ next?.range[0] === property.range[0] &&
61
+ isLeadingComment(sourceCode, comment, previous) &&
62
+ immediateCommentGapPattern.test(gap)
63
+ );
64
+ }
65
+
66
+ /**
67
+ * Report missing comments for runtime emit events.
68
+ *
69
+ * @param {object} context
70
+ * The Oxlint rule context.
71
+ * @param {object} objectExpression
72
+ * The runtime emits object to inspect.
73
+ */
74
+ function reportEventDocumentation(context, objectExpression) {
75
+ for (const property of getObjectProperties(objectExpression)) {
76
+ // Checks whether this event has the required documentation.
77
+ const hasDocumentation = hasBlockComment(context.sourceCode, property);
78
+
79
+ if (!hasDocumentation) {
80
+ context.report({ message: missingCommentMessage, node: property });
81
+
82
+ continue;
83
+ }
84
+ }
85
+ }
86
+
87
+ export default {
88
+ meta: {
89
+ docs: { description: "Require block comments for Vue runtime emits." },
90
+ type: "suggestion",
91
+ },
92
+ /**
93
+ * Create the rule's node visitors.
94
+ *
95
+ * @param {object} context
96
+ * The Oxlint rule context.
97
+ *
98
+ * @returns {object}
99
+ * The call-expression visitor for this rule.
100
+ */
101
+ createOnce(context) {
102
+ return {
103
+ /**
104
+ * Check runtime defineEmits calls for event documentation.
105
+ *
106
+ * @param {object} node
107
+ * The call expression to inspect.
108
+ */
109
+ CallExpression(node) {
110
+ if (!isVueFile(context) || !isNamedCall(node, "defineEmits")) {
111
+ return;
112
+ }
113
+
114
+ // Array and type-only forms have no runtime properties to document.
115
+ const emitsObject = getObjectArgument(node, 0);
116
+
117
+ if (emitsObject) {
118
+ reportEventDocumentation(context, emitsObject);
119
+ }
120
+ },
121
+ };
122
+ },
123
+ };