@lewishowles/lint-config 0.7.0 → 0.9.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.
@@ -301,28 +301,68 @@ export function isLeadingComment(sourceCode, comment, previous) {
301
301
  * The source node.
302
302
  *
303
303
  * @returns {boolean}
304
- * Whether an ordinary line comment immediately precedes the node.
304
+ * Whether an ordinary line comment sits directly above the node. Tool
305
+ * directive comments may sit between them.
305
306
  */
306
307
  export function hasImmediateLineComment(sourceCode, node) {
307
- // Finds the closest preceding comment.
308
- const comment = sourceCode
309
- .getAllComments()
310
- .findLast((candidate) => candidate.range[1] <= node.range[0]);
308
+ // The closest comment above the node, looking past tool directive comments.
309
+ const comment = getImmediateNonDirectiveComment(sourceCode, node);
311
310
 
312
- if (comment?.type !== "Line" || isDirectiveComment(comment)) {
311
+ if (comment?.type !== "Line") {
313
312
  return false;
314
313
  }
315
314
 
316
315
  // Checks the comments immediately around the node.
317
316
  const { next, previous } = getCommentNeighbours(sourceCode, comment);
318
- // Confirms there is no blank line before the node.
319
- const gap = sourceCode.text.slice(comment.range[1], node.range[0]);
320
317
 
321
- return (
322
- next?.range[0] === node.range[0] &&
323
- isLeadingComment(sourceCode, comment, previous) &&
324
- /^\r?\n[ \t]*$/.test(gap)
325
- );
318
+ return next?.range[0] === node.range[0] && isLeadingComment(sourceCode, comment, previous);
319
+ }
320
+
321
+ /**
322
+ * Return the closest comment above a node, skipping tool directive comments
323
+ * such as `// eslint-disable-next-line`. The comment, each directive and the
324
+ * node must each start on the line after the one before, with no blank lines.
325
+ *
326
+ * @param {object} sourceCode
327
+ * The Oxlint source code object.
328
+ * @param {object} node
329
+ * The source node.
330
+ *
331
+ * @returns {object|null}
332
+ * The comment above the node, or null when no comment sits directly above
333
+ * the node.
334
+ */
335
+ export function getImmediateNonDirectiveComment(sourceCode, node) {
336
+ // Comments before the node, in source order.
337
+ const comments = sourceCode
338
+ .getAllComments()
339
+ .filter((comment) => comment.range[1] <= node.range[0]);
340
+
341
+ // The position of the closest comment that is not a tool directive.
342
+ const commentIndex = comments.findLastIndex((comment) => !isDirectiveComment(comment));
343
+
344
+ if (commentIndex < 0) {
345
+ return null;
346
+ }
347
+
348
+ // The comment that may document the node.
349
+ const comment = comments[commentIndex];
350
+
351
+ // Where the comment or directive checked last ends.
352
+ let previousEnd = comment.range[1];
353
+
354
+ for (const next of [...comments.slice(commentIndex + 1), node]) {
355
+ // The text between the previous item and this one.
356
+ const gap = sourceCode.text.slice(previousEnd, next.range[0]);
357
+
358
+ if (!/^\r?\n[ \t]*$/.test(gap)) {
359
+ return null;
360
+ }
361
+
362
+ previousEnd = next.range[1];
363
+ }
364
+
365
+ return comment;
326
366
  }
327
367
 
328
368
  /**
@@ -334,26 +374,19 @@ export function hasImmediateLineComment(sourceCode, node) {
334
374
  * The source node.
335
375
  *
336
376
  * @returns {boolean}
337
- * Whether an ordinary block comment immediately precedes the node.
377
+ * Whether an ordinary block comment sits directly above the node. Tool
378
+ * directive comments may sit between them.
338
379
  */
339
380
  export function hasImmediateBlockComment(sourceCode, node) {
340
- // Finds the closest preceding comment.
341
- const comment = sourceCode
342
- .getAllComments()
343
- .findLast((candidate) => candidate.range[1] <= node.range[0]);
381
+ // The closest comment above the node, looking past tool directive comments.
382
+ const comment = getImmediateNonDirectiveComment(sourceCode, node);
344
383
 
345
- if (comment?.type !== "Block" || isDirectiveComment(comment)) {
384
+ if (comment?.type !== "Block") {
346
385
  return false;
347
386
  }
348
387
 
349
388
  // Checks the comments immediately around the node.
350
389
  const { next, previous } = getCommentNeighbours(sourceCode, comment);
351
- // Confirms there is no blank line before the node.
352
- const gap = sourceCode.text.slice(comment.range[1], node.range[0]);
353
390
 
354
- return (
355
- next?.range[0] === node.range[0] &&
356
- isLeadingComment(sourceCode, comment, previous) &&
357
- /^\r?\n[ \t]*$/.test(gap)
358
- );
391
+ return next?.range[0] === node.range[0] && isLeadingComment(sourceCode, comment, previous);
359
392
  }
@@ -1,8 +1,22 @@
1
1
  import { getDisplayWidth } from "./source.js";
2
2
 
3
+ // One word of comment text. A backtick code span counts as part of the word
4
+ // around it, so wrapping never splits the code inside it across lines.
5
+ const wordPattern = /(?:`[^`]*`|[^\s`]+|`)+/g;
6
+ // Text that is only an inline code span, which reads as code, not a sentence.
7
+ const codeSpanOnlyPattern = /^`[^`]*`$/;
8
+ // An inline code span anywhere in the text.
9
+ const codeSpanPattern = /`[^`]*`/;
10
+ // Text that is only a quotation, in straight or curly quotes.
11
+ const quoteOnlyPattern = /^(?:"[^"]*"|'[^']*'|“[^”]*”|‘[^’]*’)$/;
12
+
3
13
  /**
4
14
  * Wrap words to a maximum line width.
5
15
  *
16
+ * A code span is never split: one wider than the line goes whole on its own
17
+ * line. Any other word wider than the line also gets its own line, and is cut
18
+ * at the width only when nothing comes before it on the line.
19
+ *
6
20
  * @param {string} text
7
21
  * The text to wrap.
8
22
  * @param {number} width
@@ -12,8 +26,8 @@ import { getDisplayWidth } from "./source.js";
12
26
  * Wrapped lines.
13
27
  */
14
28
  export function wrapWords(text, width) {
15
- // The text's individual words.
16
- const words = text.trim().split(/\s+/).filter(Boolean);
29
+ // The text's words, with code spans kept whole.
30
+ const words = text.trim().match(wordPattern) ?? [];
17
31
  // The wrapped lines, built up in place.
18
32
  const lines = [];
19
33
 
@@ -22,6 +36,13 @@ export function wrapWords(text, width) {
22
36
 
23
37
  for (const word of words) {
24
38
  if (word.length > width && currentLine === "") {
39
+ // Code stays whole even when it overflows the line.
40
+ if (codeSpanPattern.test(word)) {
41
+ lines.push(word);
42
+
43
+ continue;
44
+ }
45
+
25
46
  for (let index = 0; index < word.length; index += width) {
26
47
  lines.push(word.slice(index, index + width));
27
48
  }
@@ -74,8 +95,8 @@ export function refillCommentLines(lines, maximumLineLength) {
74
95
  break;
75
96
  }
76
97
 
77
- // The words still waiting on the following line.
78
- const nextWords = nextLine.text.split(/\s+/).filter(Boolean);
98
+ // The next line's words, with code spans kept whole.
99
+ const nextWords = nextLine.text.match(wordPattern) ?? [];
79
100
 
80
101
  // Whether the following line was removed after giving up all its
81
102
  // words.
@@ -180,6 +201,9 @@ export function formatSentence(text) {
180
201
  /**
181
202
  * Capitalise the first letter of sentence text.
182
203
  *
204
+ * Text that starts with anything other than a letter, such as a code span, a
205
+ * quote or an emoji, is returned unchanged.
206
+ *
183
207
  * @param {string} text
184
208
  * The sentence text.
185
209
  *
@@ -194,15 +218,14 @@ export function capitaliseSentence(text) {
194
218
  return text;
195
219
  }
196
220
 
197
- // The index of the first letter character, ignoring leading punctuation.
198
- const firstLetter = trimmedText.search(/\p{L}/u);
199
-
200
- if (firstLetter < 0) {
221
+ // Code, quoted text and emoji keep their own casing, so only a sentence
222
+ // that starts with a letter is capitalised.
223
+ if (!/^\p{L}/u.test(trimmedText)) {
201
224
  return text;
202
225
  }
203
226
 
204
- // The leading word, starting from the first letter character.
205
- const leadingWord = trimmedText.slice(firstLetter).match(/^\p{L}[\p{L}\p{N}]*/u)?.[0] ?? "";
227
+ // The leading word, before any space or punctuation.
228
+ const leadingWord = trimmedText.match(/^\p{L}[\p{L}\p{N}]*/u)?.[0] ?? "";
206
229
 
207
230
  // A camelCase word (lowercase start, later uppercase) is a code identifier
208
231
  // and must keep its own casing rather than sentence casing.
@@ -211,15 +234,20 @@ export function capitaliseSentence(text) {
211
234
  }
212
235
 
213
236
  // The first letter character.
214
- const letter = trimmedText[firstLetter];
237
+ const letter = trimmedText[0];
215
238
  // The text with its first letter capitalised.
216
- const formattedText = `${trimmedText.slice(0, firstLetter)}${letter.toLocaleUpperCase()}${trimmedText.slice(firstLetter + 1)}`;
239
+ const formattedText = `${letter.toLocaleUpperCase()}${trimmedText.slice(1)}`;
217
240
 
218
241
  return text.replace(trimmedText, formattedText);
219
242
  }
220
243
 
221
244
  /**
222
- * Add terminal punctuation to sentence text.
245
+ * Add a full stop to sentence text that has no closing punctuation.
246
+ *
247
+ * Text ending in a colon introduces what follows, and text that is only a code
248
+ * span or a quotation is not a sentence, so both are returned unchanged. A
249
+ * sentence that ends in a code span or quotation gets its full stop after the
250
+ * closing mark.
223
251
  *
224
252
  * @param {string} text
225
253
  * The sentence text.
@@ -231,7 +259,13 @@ export function addTerminalPunctuation(text) {
231
259
  // The sentence text, without leading or trailing whitespace.
232
260
  const trimmedText = text.trim();
233
261
 
234
- if (trimmedText === "" || trimmedText.startsWith("@") || /[.!?]$/.test(trimmedText)) {
262
+ if (
263
+ trimmedText === "" ||
264
+ trimmedText.startsWith("@") ||
265
+ /[.!?:]$/.test(trimmedText) ||
266
+ codeSpanOnlyPattern.test(trimmedText) ||
267
+ quoteOnlyPattern.test(trimmedText)
268
+ ) {
235
269
  return text;
236
270
  }
237
271
 
package/comments.json CHANGED
@@ -19,6 +19,7 @@
19
19
  {
20
20
  "files": ["**/*.test.*", "**/*.spec.*", "**/test/**"],
21
21
  "rules": {
22
+ "comments/function-documentation": ["error", { "ignoreInlineArrows": true }],
22
23
  "comments/variable-declarations": ["error", { "rootOnly": true }]
23
24
  }
24
25
  }
package/layers.js ADDED
@@ -0,0 +1,60 @@
1
+ import base from "./base.json" with { type: "json" };
2
+ import comments from "./comments.json" with { type: "json" };
3
+ import vueConfig from "./vue.json" with { type: "json" };
4
+
5
+ // The Vue layer. It includes base as an object because Oxlint rejects the file
6
+ // path that vue.json uses to extend base.
7
+ export const vue = { ...vueConfig, extends: [base] };
8
+
9
+ /**
10
+ * Build a Vite+ lint block from the selected layers and local settings.
11
+ *
12
+ * @param {object[]} layers
13
+ * The layer objects to extend. When two layers set the same env or global,
14
+ * the later layer's value is used.
15
+ * @param {object} local
16
+ * Project settings that take priority over the layers. Its extends list is
17
+ * ignored, so every layer must be passed in layers.
18
+ *
19
+ * @returns {object}
20
+ * A lint block with inherited environments and globals at the top level.
21
+ */
22
+ export function lintConfig(layers, local = {}) {
23
+ // The environments inherited from the selected layers.
24
+ const env = {};
25
+ // The globals inherited from the selected layers.
26
+ const globals = {};
27
+
28
+ for (const layer of layers) {
29
+ collectEnvAndGlobals(layer, env, globals);
30
+ }
31
+
32
+ return {
33
+ ...local,
34
+ env: { ...env, ...local.env },
35
+ globals: { ...globals, ...local.globals },
36
+ extends: layers,
37
+ };
38
+ }
39
+
40
+ /**
41
+ * Copy a layer's env and globals into the collected values. The layers it
42
+ * extends are copied first, so the layer's own values win.
43
+ *
44
+ * @param {object} layer
45
+ * The layer to read, along with every layer it extends.
46
+ * @param {object} env
47
+ * The collected environments, updated in place.
48
+ * @param {object} globals
49
+ * The collected globals, updated in place.
50
+ */
51
+ function collectEnvAndGlobals(layer, env, globals) {
52
+ for (const extendedLayer of layer.extends ?? []) {
53
+ collectEnvAndGlobals(extendedLayer, env, globals);
54
+ }
55
+
56
+ Object.assign(env, layer.env);
57
+ Object.assign(globals, layer.globals);
58
+ }
59
+
60
+ export { base, comments };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lewishowles/lint-config",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Shared oxlint configuration for Lewis Howles projects",
5
5
  "keywords": [
6
6
  "config",
@@ -24,18 +24,23 @@
24
24
  "comments",
25
25
  "comments.json",
26
26
  "imports.json",
27
+ "layers.js",
28
+ "testing",
27
29
  "vue.json",
28
30
  "README.md"
29
31
  ],
30
32
  "type": "module",
31
33
  "imports": {
32
- "#comments/*": "./comments/*"
34
+ "#comments/*": "./comments/*",
35
+ "#testing/*": "./testing/*"
33
36
  },
34
37
  "exports": {
35
38
  "./base.json": "./base.json",
36
39
  "./comments.json": "./comments.json",
37
40
  "./comments/plugin": "./comments/plugin.js",
38
41
  "./imports.json": "./imports.json",
42
+ "./layers": "./layers.js",
43
+ "./testing/plugin": "./testing/plugin.js",
39
44
  "./vue.json": "./vue.json"
40
45
  },
41
46
  "publishConfig": {
@@ -46,15 +51,15 @@
46
51
  "lint:fix": "vp check --fix",
47
52
  "prepare": "vp config --no-agent",
48
53
  "publint": "publint",
49
- "test:unit": "node --test test/comments/*.test.js"
54
+ "test:unit": "node --test test/comments/*.test.js test/layers/*.test.js test/testing/*.test.js"
50
55
  },
51
56
  "devDependencies": {
52
57
  "publint": "^0.3.22",
53
- "vite-plus": "0.2.8"
58
+ "vite-plus": "1.0.0"
54
59
  },
55
60
  "peerDependencies": {
56
61
  "@stylistic/eslint-plugin": "*",
57
- "vite-plus": "0.2.8"
62
+ "vite-plus": "^1.0.0"
58
63
  },
59
64
  "engines": {
60
65
  "node": ">=20"
@@ -0,0 +1,8 @@
1
+ import noTextLookups from "./rules/no-text-lookups.js";
2
+
3
+ export default {
4
+ meta: { name: "testing" },
5
+ rules: {
6
+ "no-text-lookups": noTextLookups,
7
+ },
8
+ };
@@ -0,0 +1,148 @@
1
+ // The array methods that count as a lookup when their callback reads an
2
+ // element's text.
3
+ const searchMethods = new Set([
4
+ "filter",
5
+ "find",
6
+ "findLast",
7
+ "findIndex",
8
+ "findLastIndex",
9
+ "some",
10
+ "every",
11
+ ]);
12
+
13
+ // The DOM properties that hold an element's visible text.
14
+ const textProperties = new Set(["textContent", "innerText"]);
15
+
16
+ /**
17
+ * Return whether a callback body reads an element's text anywhere inside it.
18
+ *
19
+ * @param {object} node
20
+ * The callback body, or a node inside it while searching.
21
+ *
22
+ * @returns {boolean}
23
+ * Whether any part of the body calls text() or reads textContent or
24
+ * innerText.
25
+ */
26
+ function containsTextRead(node) {
27
+ // A nested search callback is reported at its own call, so it isn't
28
+ // counted again here.
29
+ if (isSearchCallback(node)) {
30
+ return false;
31
+ }
32
+
33
+ if (isTextRead(node)) {
34
+ return true;
35
+ }
36
+
37
+ for (const [key, child] of Object.entries(node)) {
38
+ if (key === "parent") {
39
+ continue;
40
+ }
41
+
42
+ if (Array.isArray(child)) {
43
+ for (const entry of child) {
44
+ if (entry?.type && containsTextRead(entry)) {
45
+ return true;
46
+ }
47
+ }
48
+ } else if (child?.type && containsTextRead(child)) {
49
+ return true;
50
+ }
51
+ }
52
+
53
+ return false;
54
+ }
55
+
56
+ /**
57
+ * Return whether a node is an inline callback passed to an array search, such
58
+ * as the arrow function in items.find(item => item.id === 1).
59
+ *
60
+ * @param {object|undefined} node
61
+ * The node to inspect, or undefined when a call has no arguments.
62
+ *
63
+ * @returns {boolean}
64
+ * Whether the rule checks this callback's body for text reads.
65
+ */
66
+ function isSearchCallback(node) {
67
+ if (node?.type !== "ArrowFunctionExpression" && node?.type !== "FunctionExpression") {
68
+ return false;
69
+ }
70
+
71
+ // The call the function is passed to, if it is passed to one.
72
+ const call = node.parent;
73
+
74
+ return (
75
+ call?.type === "CallExpression" &&
76
+ call.arguments[0] === node &&
77
+ call.callee.type === "MemberExpression" &&
78
+ !call.callee.computed &&
79
+ searchMethods.has(call.callee.property.name)
80
+ );
81
+ }
82
+
83
+ /**
84
+ * Return whether a node itself reads an element's text, by calling text() or
85
+ * reading textContent or innerText.
86
+ *
87
+ * @param {object} node
88
+ * The AST node to inspect.
89
+ *
90
+ * @returns {boolean}
91
+ * Whether the node calls text() or reads a DOM text property.
92
+ */
93
+ function isTextRead(node) {
94
+ if (
95
+ node.type === "CallExpression" &&
96
+ node.callee.type === "MemberExpression" &&
97
+ !node.callee.computed &&
98
+ node.callee.property.name === "text"
99
+ ) {
100
+ return true;
101
+ }
102
+
103
+ return (
104
+ node.type === "MemberExpression" && !node.computed && textProperties.has(node.property.name)
105
+ );
106
+ }
107
+
108
+ export default {
109
+ meta: {
110
+ docs: { description: "Find test elements by data-test attributes instead of visible text." },
111
+ type: "problem",
112
+ },
113
+
114
+ /**
115
+ * Create the rule's node visitor.
116
+ *
117
+ * @param {object} context
118
+ * The Oxlint rule context.
119
+ *
120
+ * @returns {object}
121
+ * The visitor for array search calls.
122
+ */
123
+ createOnce(context) {
124
+ return {
125
+ /**
126
+ * Report an array search when its inline callback reads visible
127
+ * text.
128
+ *
129
+ * @param {object} node
130
+ * The call expression being checked.
131
+ */
132
+ CallExpression(node) {
133
+ // Only an inline callback is checked, because a callback passed
134
+ // by name has its body somewhere else.
135
+ const callback = node.arguments[0];
136
+
137
+ if (!isSearchCallback(callback) || !containsTextRead(callback.body)) {
138
+ return;
139
+ }
140
+
141
+ context.report({
142
+ message: "Find test elements by a data-test attribute instead of visible text.",
143
+ node,
144
+ });
145
+ },
146
+ };
147
+ },
148
+ };
package/vue.json CHANGED
@@ -12,6 +12,7 @@
12
12
  },
13
13
  "rules": {
14
14
  "vue/no-arrow-functions-in-watch": "error",
15
+ "vue/no-async-in-computed-properties": "error",
15
16
  "vue/no-deprecated-data-object-declaration": "error",
16
17
  "vue/no-deprecated-delete-set": "error",
17
18
  "vue/no-deprecated-destroyed-lifecycle": "error",
@@ -20,6 +21,7 @@
20
21
  "vue/no-deprecated-vue-config-keycodes": "error",
21
22
  "vue/no-export-in-script-setup": "error",
22
23
  "vue/no-lifecycle-after-await": "error",
24
+ "vue/no-side-effects-in-computed-properties": "error",
23
25
  "vue/prefer-import-from-vue": "error",
24
26
  "vue/return-in-computed-property": "error",
25
27
  "vue/valid-define-emits": "error",