|
| 1 | +import type { TSESTree as Tree, TSESLint } from '@typescript-eslint/utils' |
| 2 | +import { createRule } from '../utils' |
| 3 | +import { ORDER_KEYS } from './keys' |
| 4 | + |
| 5 | +type MessageIds = 'default' |
| 6 | + |
| 7 | +type Options = [] |
| 8 | + |
| 9 | +export const rule = createRule<MessageIds, Options>({ |
| 10 | + name: 'nuxt-config-keys-order', |
| 11 | + meta: { |
| 12 | + type: 'suggestion', |
| 13 | + docs: { |
| 14 | + description: 'Prefer recommended order of Nuxt config properties', |
| 15 | + }, |
| 16 | + schema: [], |
| 17 | + messages: { |
| 18 | + default: 'Expected config key "{{a}}" to come before "{{b}}"', |
| 19 | + }, |
| 20 | + fixable: 'code', |
| 21 | + }, |
| 22 | + defaultOptions: [], |
| 23 | + create(context) { |
| 24 | + if (!context.filename.match(/nuxt\.config\.[mc]?[jt]sx?$/)) { |
| 25 | + return {} |
| 26 | + } |
| 27 | + |
| 28 | + return { |
| 29 | + ExportDefaultDeclaration(node) { |
| 30 | + let object: Tree.ObjectExpression | undefined |
| 31 | + if (node.declaration.type === 'ObjectExpression') { |
| 32 | + object = node.declaration |
| 33 | + } |
| 34 | + else if (node.declaration.type === 'CallExpression' && node.declaration.arguments[0].type === 'ObjectExpression') { |
| 35 | + object = node.declaration.arguments[0] |
| 36 | + } |
| 37 | + if (!object) { |
| 38 | + return |
| 39 | + } |
| 40 | + |
| 41 | + const hasFixes = sort(context, object) |
| 42 | + if (!hasFixes) { |
| 43 | + const envProps = object.properties.filter(i => i.type === 'Property' && i.key.type === 'Identifier' && i.key.name.startsWith('$')) as Tree.Property[] |
| 44 | + for (const prop of envProps) { |
| 45 | + if (prop.value.type === 'ObjectExpression') |
| 46 | + sort(context, prop.value) |
| 47 | + } |
| 48 | + } |
| 49 | + }, |
| 50 | + } |
| 51 | + }, |
| 52 | +}) |
| 53 | + |
| 54 | +function sort(context: TSESLint.RuleContext<MessageIds, Options>, node: Tree.ObjectExpression) { |
| 55 | + return sortAst( |
| 56 | + context, |
| 57 | + node, |
| 58 | + node.properties as Tree.Property[], |
| 59 | + (prop) => { |
| 60 | + if (prop.type === 'Property') |
| 61 | + return getString(prop.key) |
| 62 | + return null |
| 63 | + }, |
| 64 | + sortKeys, |
| 65 | + ) |
| 66 | +} |
| 67 | + |
| 68 | +function sortKeys(a: string, b: string) { |
| 69 | + const indexA = ORDER_KEYS.findIndex(k => typeof k === 'string' ? k === a : k.test(a)) |
| 70 | + const indexB = ORDER_KEYS.findIndex(k => typeof k === 'string' ? k === b : k.test(b)) |
| 71 | + if (indexA === -1 && indexB !== -1) |
| 72 | + return 1 |
| 73 | + if (indexA !== -1 && indexB === -1) |
| 74 | + return -1 |
| 75 | + if (indexA < indexB) |
| 76 | + return -1 |
| 77 | + if (indexA > indexB) |
| 78 | + return 1 |
| 79 | + return a.localeCompare(b) |
| 80 | +} |
| 81 | + |
| 82 | +// Ported from https://github.com/gauben/eslint-plugin-command/blob/04efa47a2319a5f9afb395cf0efccc9cb111058d/src/commands/keep-sorted.ts#L138-L144 |
| 83 | +function sortAst<T extends Tree.Node>( |
| 84 | + context: TSESLint.RuleContext<MessageIds, Options>, |
| 85 | + node: Tree.Node, |
| 86 | + list: T[], |
| 87 | + getName: (node: T) => string | (string | null)[] | null, |
| 88 | + sort: (a: string, b: string) => number = (a, b) => a.localeCompare(b), |
| 89 | + insertComma = true, |
| 90 | +) { |
| 91 | + const firstToken = context.sourceCode.getFirstToken(node)! |
| 92 | + const lastToken = context.sourceCode.getLastToken(node)! |
| 93 | + if (!firstToken || !lastToken) |
| 94 | + return false |
| 95 | + |
| 96 | + if (list.length < 2) |
| 97 | + return false |
| 98 | + |
| 99 | + const reordered = list.slice() |
| 100 | + const ranges = new Map<typeof list[number], [number, number, string]>() |
| 101 | + const names = new Map<typeof list[number], (string | null)[] | null>() |
| 102 | + |
| 103 | + const rangeStart = Math.max( |
| 104 | + firstToken.range[1], |
| 105 | + context.sourceCode.getIndexFromLoc({ |
| 106 | + line: list[0].loc.start.line, |
| 107 | + column: 0, |
| 108 | + }), |
| 109 | + ) |
| 110 | + |
| 111 | + let rangeEnd = rangeStart |
| 112 | + for (let i = 0; i < list.length; i++) { |
| 113 | + const item = list[i] |
| 114 | + let name = getName(item) |
| 115 | + if (typeof name === 'string') |
| 116 | + name = [name] |
| 117 | + names.set(item, name) |
| 118 | + |
| 119 | + let lastRange = item.range[1] |
| 120 | + const nextToken = context.sourceCode.getTokenAfter(item) |
| 121 | + if (nextToken?.type === 'Punctuator' && nextToken.value === ',') |
| 122 | + lastRange = nextToken.range[1] |
| 123 | + const nextChar = context.sourceCode.getText()[lastRange] |
| 124 | + |
| 125 | + // Insert comma if it's the last item without a comma |
| 126 | + let text = getTextOf(context.sourceCode, [rangeEnd, lastRange]) |
| 127 | + if (nextToken === lastToken && insertComma) |
| 128 | + text += ',' |
| 129 | + |
| 130 | + // Include subsequent newlines |
| 131 | + if (nextChar === '\n') { |
| 132 | + lastRange++ |
| 133 | + text += '\n' |
| 134 | + } |
| 135 | + |
| 136 | + ranges.set(item, [rangeEnd, lastRange, text]) |
| 137 | + rangeEnd = lastRange |
| 138 | + } |
| 139 | + |
| 140 | + const segments: [number, number][] = [] |
| 141 | + let segmentStart: number = -1 |
| 142 | + for (let i = 0; i < list.length; i++) { |
| 143 | + if (names.get(list[i]) == null) { |
| 144 | + if (segmentStart > -1) |
| 145 | + segments.push([segmentStart, i]) |
| 146 | + segmentStart = -1 |
| 147 | + } |
| 148 | + else { |
| 149 | + if (segmentStart === -1) |
| 150 | + segmentStart = i |
| 151 | + } |
| 152 | + } |
| 153 | + if (segmentStart > -1 && segmentStart !== list.length - 1) |
| 154 | + segments.push([segmentStart, list.length]) |
| 155 | + |
| 156 | + for (const [start, end] of segments) { |
| 157 | + reordered.splice( |
| 158 | + start, |
| 159 | + end - start, |
| 160 | + ...reordered |
| 161 | + .slice(start, end) |
| 162 | + .sort((a, b) => { |
| 163 | + const nameA: (string | null)[] = names.get(a)! |
| 164 | + const nameB: (string | null)[] = names.get(b)! |
| 165 | + |
| 166 | + const length = Math.max(nameA.length, nameB.length) |
| 167 | + for (let i = 0; i < length; i++) { |
| 168 | + const a = nameA[i] |
| 169 | + const b = nameB[i] |
| 170 | + if (a == null || b == null || a === b) |
| 171 | + continue |
| 172 | + return sort(a, b) |
| 173 | + } |
| 174 | + return 0 |
| 175 | + }), |
| 176 | + ) |
| 177 | + } |
| 178 | + |
| 179 | + const changed = reordered.some((prop, i) => prop !== list[i]) |
| 180 | + if (!changed) |
| 181 | + return false |
| 182 | + |
| 183 | + const newContent = reordered |
| 184 | + .map(i => ranges.get(i)![2]) |
| 185 | + .join('') |
| 186 | + |
| 187 | + // console.log({ |
| 188 | + // reordered, |
| 189 | + // newContent, |
| 190 | + // oldContent: ctx.context.sourceCode.text.slice(rangeStart, rangeEnd), |
| 191 | + // }) |
| 192 | + |
| 193 | + context.report({ |
| 194 | + node, |
| 195 | + messageId: 'default', |
| 196 | + data: { |
| 197 | + a: names.get(reordered[0])![0]!, |
| 198 | + b: names.get(reordered[1])![0]!, |
| 199 | + }, |
| 200 | + fix(fixer) { |
| 201 | + return fixer.replaceTextRange([rangeStart, rangeEnd], newContent) |
| 202 | + }, |
| 203 | + }) |
| 204 | +} |
| 205 | + |
| 206 | +function getTextOf(sourceCode: TSESLint.SourceCode, node?: Tree.Node | Tree.Token | Tree.Range | null) { |
| 207 | + if (!node) |
| 208 | + return '' |
| 209 | + if (Array.isArray(node)) |
| 210 | + return sourceCode.text.slice(node[0], node[1]) |
| 211 | + return sourceCode.getText(node) |
| 212 | +} |
| 213 | + |
| 214 | +function getString(node: Tree.Node): string | null { |
| 215 | + if (node.type === 'Identifier') |
| 216 | + return node.name |
| 217 | + if (node.type === 'Literal') |
| 218 | + return String(node.raw) |
| 219 | + return null |
| 220 | +} |
0 commit comments