@naturalcycles/dev-lib 20.9.0 → 20.10.1

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.
@@ -503,14 +503,14 @@ export default {
503
503
  '@typescript-eslint/dot-notation': 0, // not always desireable
504
504
  '@typescript-eslint/consistent-indexed-object-style': 0, // Record looses the name of the key
505
505
  '@typescript-eslint/no-unsafe-enum-comparison': 0, // not practically helpful
506
- // stylistic
507
- '@stylistic/padding-line-between-statements': [
508
- 2,
509
- { blankLine: 'always', prev: 'function', next: '*' },
510
- { blankLine: 'always', prev: '*', next: 'function' },
511
- { blankLine: 'always', prev: 'class', next: '*' },
512
- { blankLine: 'always', prev: '*', next: 'class' },
513
- ],
514
- '@stylistic/lines-between-class-members': [2, 'always', { exceptAfterSingleLine: true }],
506
+ // stylistic - replaced by custom oxlint rules
507
+ // '@stylistic/padding-line-between-statements': [
508
+ // 2,
509
+ // { blankLine: 'always', prev: 'function', next: '*' },
510
+ // { blankLine: 'always', prev: '*', next: 'function' },
511
+ // { blankLine: 'always', prev: 'class', next: '*' },
512
+ // { blankLine: 'always', prev: '*', next: 'class' },
513
+ // ],
514
+ // '@stylistic/lines-between-class-members': [2, 'always', { exceptAfterSingleLine: true }],
515
515
  },
516
516
  }
@@ -13,7 +13,6 @@ import eslintPluginOxlint from 'eslint-plugin-oxlint'
13
13
  import eslintPluginVitest from '@vitest/eslint-plugin'
14
14
  import eslintPluginImportX from 'eslint-plugin-import-x'
15
15
  import eslintPluginSimpleImportSort from 'eslint-plugin-simple-import-sort'
16
- import eslintPluginStylistic from '@stylistic/eslint-plugin'
17
16
  import eslintRules from './eslint-rules.js'
18
17
  import eslintVueRules from './eslint-vue-rules.js'
19
18
  import eslintVitestRules from './eslint-vitest-rules.js'
@@ -106,7 +105,7 @@ function getConfig(tsconfigPath) {
106
105
  // 'unused-imports': require('eslint-plugin-unused-imports'), // disabled in favor of biome rules
107
106
  'simple-import-sort': eslintPluginSimpleImportSort,
108
107
  // jsdoc: eslintPluginJsdoc, // oxlint
109
- '@stylistic': eslintPluginStylistic,
108
+ // '@stylistic': eslintPluginStylistic, // oxlint custom plugin
110
109
  },
111
110
  languageOptions: {
112
111
  ecmaVersion: 'latest',
@@ -0,0 +1,306 @@
1
+ const BLANK_LINE_PATTERN = /(?:\r?\n)[ \t]*(?:\r?\n)/
2
+ const FALLBACK_PADDING_OPTIONS = [
3
+ { blankLine: 'always', prev: 'function', next: '*' },
4
+ { blankLine: 'always', prev: '*', next: 'function' },
5
+ { blankLine: 'always', prev: 'class', next: '*' },
6
+ { blankLine: 'always', prev: '*', next: 'class' },
7
+ ]
8
+
9
+ /**
10
+ * Utility that unwraps export declarations so the underlying declaration
11
+ * (function/class) can be inspected.
12
+ */
13
+ function unwrapExported(node) {
14
+ if (!node) return null
15
+ if (node.type === 'ExportNamedDeclaration' || node.type === 'ExportDefaultDeclaration') {
16
+ return node.declaration || null
17
+ }
18
+ return node
19
+ }
20
+
21
+ function isFunctionLikeStatement(node) {
22
+ const inner = unwrapExported(node)
23
+ if (!inner) return false
24
+
25
+ return inner.type === 'FunctionDeclaration'
26
+ }
27
+
28
+ function isFunctionOverloadStatement(node) {
29
+ const inner = unwrapExported(node)
30
+ if (!inner) return false
31
+
32
+ return inner.type === 'TSDeclareFunction'
33
+ }
34
+
35
+ function isClassLikeStatement(node) {
36
+ const inner = unwrapExported(node)
37
+ if (!inner) return false
38
+
39
+ return inner.type === 'ClassDeclaration'
40
+ }
41
+
42
+ function describeStatement(node) {
43
+ if (!node) return 'statement'
44
+ if (isFunctionLikeStatement(node)) return 'function declaration'
45
+ if (isClassLikeStatement(node)) return 'class declaration'
46
+ return 'statement'
47
+ }
48
+
49
+ function isMethodOverload(node) {
50
+ if (!node) return false
51
+ if (node.type !== 'MethodDefinition' && node.type !== 'TSAbstractMethodDefinition') return false
52
+ return node.value && node.value.type === 'TSEmptyBodyFunctionExpression'
53
+ }
54
+
55
+ function hasBlankLineBetween(sourceCode, prevNode, nextNode) {
56
+ if (!prevNode || !nextNode) return false
57
+ if (!prevNode.range || !nextNode.range) return false
58
+ if (prevNode.range[1] >= nextNode.range[0]) return false
59
+
60
+ const between = sourceCode.text.slice(prevNode.range[1], nextNode.range[0])
61
+ return BLANK_LINE_PATTERN.test(between)
62
+ }
63
+
64
+ function insertBlankLineBeforeNext(fixer, sourceCode, prevNode, nextNode) {
65
+ if (!prevNode || !nextNode) return null
66
+ if (!prevNode.range || !nextNode.range) return null
67
+ if (prevNode.range[1] >= nextNode.range[0]) return null
68
+
69
+ const commentsBetween = sourceCode
70
+ .getCommentsBefore(nextNode)
71
+ ?.filter(comment => comment.range && comment.range[0] >= prevNode.range[1])
72
+ ?.sort((a, b) => a.range[0] - b.range[0])
73
+ const insertionTarget =
74
+ commentsBetween && commentsBetween.length > 0 ? commentsBetween[0].range[0] : nextNode.range[0]
75
+
76
+ const between = sourceCode.text.slice(prevNode.range[1], insertionTarget)
77
+ const hasLinebreak = /\r?\n/.test(between)
78
+ const linebreak = sourceCode.text.includes('\r\n') ? '\r\n' : '\n'
79
+ const text = hasLinebreak ? linebreak : linebreak + linebreak
80
+
81
+ return fixer.insertTextBeforeRange([insertionTarget, insertionTarget], text)
82
+ }
83
+
84
+ function matchesSelector(selector, candidate) {
85
+ if (selector == null) return false
86
+ if (selector === '*') return true
87
+ if (Array.isArray(selector)) return selector.some(item => matchesSelector(item, candidate))
88
+ return selector === candidate
89
+ }
90
+
91
+ const paddingLineBetweenStatementsRule = {
92
+ meta: {
93
+ type: 'layout',
94
+ docs: {
95
+ description: 'Require a blank line between functions and classes and surrounding statements',
96
+ },
97
+ fixable: 'whitespace',
98
+ schema: {
99
+ type: 'array',
100
+ items: {
101
+ type: 'object',
102
+ properties: {
103
+ blankLine: { enum: ['always'] },
104
+ prev: { anyOf: [{ enum: ['*', 'function', 'class'] }, { type: 'array' }] },
105
+ next: { anyOf: [{ enum: ['*', 'function', 'class'] }, { type: 'array' }] },
106
+ },
107
+ required: ['blankLine', 'prev', 'next'],
108
+ additionalProperties: false,
109
+ },
110
+ },
111
+ defaultOptions: [],
112
+ messages: {
113
+ expectedBlankLine: 'Expected blank line between {{prev}} and {{next}}.',
114
+ },
115
+ },
116
+ create(context) {
117
+ const sourceCode = context.sourceCode
118
+ const options =
119
+ Array.isArray(context.options) && context.options.length > 0
120
+ ? context.options
121
+ : FALLBACK_PADDING_OPTIONS
122
+
123
+ const requiresBlankLine = {
124
+ beforeFunction: false,
125
+ afterFunction: false,
126
+ beforeClass: false,
127
+ afterClass: false,
128
+ }
129
+
130
+ for (const option of options) {
131
+ if (!option || option.blankLine !== 'always') continue
132
+
133
+ if (matchesSelector(option.prev, 'function') && matchesSelector(option.next, '*')) {
134
+ requiresBlankLine.afterFunction = true
135
+ }
136
+ if (matchesSelector(option.prev, '*') && matchesSelector(option.next, 'function')) {
137
+ requiresBlankLine.beforeFunction = true
138
+ }
139
+ if (matchesSelector(option.prev, 'class') && matchesSelector(option.next, '*')) {
140
+ requiresBlankLine.afterClass = true
141
+ }
142
+ if (matchesSelector(option.prev, '*') && matchesSelector(option.next, 'class')) {
143
+ requiresBlankLine.beforeClass = true
144
+ }
145
+ }
146
+
147
+ const shouldCheckFunctions = requiresBlankLine.beforeFunction || requiresBlankLine.afterFunction
148
+ const shouldCheckClasses = requiresBlankLine.beforeClass || requiresBlankLine.afterClass
149
+
150
+ if (!shouldCheckFunctions && !shouldCheckClasses) {
151
+ // Nothing to enforce, bail early.
152
+ return {}
153
+ }
154
+
155
+ function needsBlankLine(prevNode, nextNode) {
156
+ if (shouldCheckFunctions) {
157
+ if (
158
+ requiresBlankLine.afterFunction &&
159
+ isFunctionLikeStatement(prevNode) &&
160
+ !isFunctionOverloadStatement(nextNode)
161
+ ) {
162
+ return true
163
+ }
164
+ if (
165
+ requiresBlankLine.beforeFunction &&
166
+ isFunctionLikeStatement(nextNode) &&
167
+ !isFunctionOverloadStatement(prevNode)
168
+ ) {
169
+ return true
170
+ }
171
+ }
172
+ if (shouldCheckClasses) {
173
+ if (requiresBlankLine.afterClass && isClassLikeStatement(prevNode)) return true
174
+ if (requiresBlankLine.beforeClass && isClassLikeStatement(nextNode)) return true
175
+ }
176
+ return false
177
+ }
178
+
179
+ function checkStatements(statements) {
180
+ if (!Array.isArray(statements) || statements.length < 2) return
181
+
182
+ for (let i = 1; i < statements.length; i += 1) {
183
+ const prevNode = statements[i - 1]
184
+ const nextNode = statements[i]
185
+
186
+ if (!prevNode || !nextNode) continue
187
+ if (!needsBlankLine(prevNode, nextNode)) continue
188
+ if (hasBlankLineBetween(sourceCode, prevNode, nextNode)) continue
189
+
190
+ context.report({
191
+ node: nextNode,
192
+ messageId: 'expectedBlankLine',
193
+ data: {
194
+ prev: describeStatement(prevNode),
195
+ next: describeStatement(nextNode),
196
+ },
197
+ fix(fixer) {
198
+ return insertBlankLineBeforeNext(fixer, sourceCode, prevNode, nextNode)
199
+ },
200
+ })
201
+ }
202
+ }
203
+
204
+ return {
205
+ Program(node) {
206
+ checkStatements(node.body)
207
+ },
208
+ BlockStatement(node) {
209
+ checkStatements(node.body)
210
+ },
211
+ StaticBlock(node) {
212
+ checkStatements(node.body)
213
+ },
214
+ SwitchCase(node) {
215
+ checkStatements(node.consequent)
216
+ },
217
+ }
218
+ },
219
+ }
220
+
221
+ function isSingleLine(node, sourceCode) {
222
+ if (!node || !node.range) return false
223
+ const start = sourceCode.getLocFromIndex(node.range[0])
224
+ const endIndex = node.range[1] > node.range[0] ? node.range[1] - 1 : node.range[1]
225
+ const end = sourceCode.getLocFromIndex(endIndex)
226
+ return start.line === end.line
227
+ }
228
+
229
+ const linesBetweenClassMembersRule = {
230
+ meta: {
231
+ type: 'layout',
232
+ docs: {
233
+ description: 'Require blank lines between class members',
234
+ },
235
+ fixable: 'whitespace',
236
+ schema: [
237
+ { enum: ['always'] },
238
+ {
239
+ type: 'object',
240
+ properties: {
241
+ exceptAfterSingleLine: { type: 'boolean' },
242
+ },
243
+ additionalProperties: false,
244
+ },
245
+ ],
246
+ defaultOptions: ['always', { exceptAfterSingleLine: false }],
247
+ messages: {
248
+ expectedBlankLine: 'Expected blank line between class members.',
249
+ },
250
+ },
251
+ create(context) {
252
+ const sourceCode = context.sourceCode
253
+ const mode = context.options?.[0] || 'always'
254
+ const exceptAfterSingleLine =
255
+ context.options?.[1]?.exceptAfterSingleLine !== undefined
256
+ ? Boolean(context.options[1].exceptAfterSingleLine)
257
+ : true
258
+ const exceptAfterOverload = true
259
+
260
+ if (mode !== 'always') {
261
+ return {}
262
+ }
263
+
264
+ function checkClassBody(body) {
265
+ if (!Array.isArray(body) || body.length < 2) return
266
+
267
+ const members = exceptAfterOverload ? body.filter(member => !isMethodOverload(member)) : body
268
+ if (members.length < 2) return
269
+
270
+ for (let i = 1; i < members.length; i += 1) {
271
+ const prevElement = members[i - 1]
272
+ const nextElement = members[i]
273
+ if (!prevElement || !nextElement) continue
274
+
275
+ if (exceptAfterSingleLine && isSingleLine(prevElement, sourceCode)) continue
276
+ if (hasBlankLineBetween(sourceCode, prevElement, nextElement)) continue
277
+
278
+ context.report({
279
+ node: nextElement,
280
+ messageId: 'expectedBlankLine',
281
+ fix(fixer) {
282
+ return insertBlankLineBeforeNext(fixer, sourceCode, prevElement, nextElement)
283
+ },
284
+ })
285
+ }
286
+ }
287
+
288
+ return {
289
+ ClassBody(node) {
290
+ checkClassBody(node.body)
291
+ },
292
+ }
293
+ },
294
+ }
295
+
296
+ const stylisticPlugin = {
297
+ meta: {
298
+ name: '@stylistic',
299
+ },
300
+ rules: {
301
+ 'padding-line-between-statements': paddingLineBetweenStatementsRule,
302
+ 'lines-between-class-members': linesBetweenClassMembersRule,
303
+ },
304
+ }
305
+
306
+ export default stylisticPlugin
@@ -11,7 +11,16 @@
11
11
  "promise",
12
12
  "vitest"
13
13
  ],
14
+ "jsPlugins": ["./oxlint-plugin-stylistic.mjs"],
14
15
  "rules": {
16
+ "@stylistic/padding-line-between-statements": [
17
+ 2,
18
+ { "blankLine": "always", "prev": "function", "next": "*" },
19
+ { "blankLine": "always", "prev": "*", "next": "function" },
20
+ { "blankLine": "always", "prev": "class", "next": "*" },
21
+ { "blankLine": "always", "prev": "*", "next": "class" }
22
+ ],
23
+ "@stylistic/lines-between-class-members": [2, "always", { "exceptAfterSingleLine": true }],
15
24
  "no-this-alias": 0,
16
25
  "no-async-promise-executor": 0,
17
26
  "no-standalone-expect": 0,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@naturalcycles/dev-lib",
3
3
  "type": "module",
4
- "version": "20.9.0",
4
+ "version": "20.10.1",
5
5
  "dependencies": {
6
6
  "@biomejs/biome": "^2",
7
7
  "@commitlint/cli": "^20",
@@ -10,7 +10,6 @@
10
10
  "@inquirer/prompts": "^7",
11
11
  "@naturalcycles/js-lib": "^15",
12
12
  "@naturalcycles/nodejs-lib": "^15",
13
- "@stylistic/eslint-plugin": "^5",
14
13
  "@vitest/coverage-v8": "^4",
15
14
  "@vitest/eslint-plugin": "^1",
16
15
  "eslint": "^9",