|
| 1 | +import fs from 'node:fs'; |
| 2 | +import path from 'node:path'; |
| 3 | + |
| 4 | +import { localRepoPath, prettify } from './utils.ts'; |
| 5 | + |
| 6 | +type Mode = 'check' | 'write'; |
| 7 | + |
| 8 | +interface JsdocLine { |
| 9 | + content: string; |
| 10 | + prefix: string; |
| 11 | +} |
| 12 | + |
| 13 | +interface LineUpdate { |
| 14 | + end: number; |
| 15 | + lines: Array<string>; |
| 16 | + start: number; |
| 17 | +} |
| 18 | + |
| 19 | +const languageExtensions = new Map([ |
| 20 | + ['graphql', 'graphql'], |
| 21 | + ['gql', 'graphql'], |
| 22 | + ['javascript', 'js'], |
| 23 | + ['js', 'js'], |
| 24 | + ['jsx', 'jsx'], |
| 25 | + ['ts', 'ts'], |
| 26 | + ['tsx', 'tsx'], |
| 27 | + ['typescript', 'ts'], |
| 28 | +]); |
| 29 | + |
| 30 | +const mode = parseMode(process.argv.slice(2)); |
| 31 | +const sourceDir = localRepoPath('src'); |
| 32 | +const allIssues = []; |
| 33 | +let changedFiles = 0; |
| 34 | + |
| 35 | +const results = await Promise.all( |
| 36 | + Array.from(sourceFiles(sourceDir), async (filePath) => { |
| 37 | + const source = fs.readFileSync(filePath, 'utf-8'); |
| 38 | + const result = await prettifyFile(filePath, source); |
| 39 | + return { filePath, result, source }; |
| 40 | + }), |
| 41 | +); |
| 42 | + |
| 43 | +for (const { filePath, result, source } of results) { |
| 44 | + allIssues.push(...result.issues); |
| 45 | + |
| 46 | + if (mode === 'write' && result.source !== source) { |
| 47 | + fs.writeFileSync(filePath, result.source); |
| 48 | + changedFiles++; |
| 49 | + } |
| 50 | +} |
| 51 | + |
| 52 | +if (allIssues.length > 0) { |
| 53 | + for (const message of allIssues) { |
| 54 | + console.error(message); |
| 55 | + } |
| 56 | + process.exitCode = 1; |
| 57 | +} else if (mode === 'write') { |
| 58 | + console.log(`Prettified JSDoc examples in ${changedFiles} file(s).`); |
| 59 | +} |
| 60 | + |
| 61 | +function parseMode(args: ReadonlyArray<string>): Mode { |
| 62 | + if (args.length === 1 && args[0] === '--check') { |
| 63 | + return 'check'; |
| 64 | + } |
| 65 | + if (args.length === 1 && args[0] === '--write') { |
| 66 | + return 'write'; |
| 67 | + } |
| 68 | + |
| 69 | + console.error('Usage: prettier-examples.ts --check|--write'); |
| 70 | + process.exit(1); |
| 71 | +} |
| 72 | + |
| 73 | +async function prettifyFile( |
| 74 | + filePath: string, |
| 75 | + source: string, |
| 76 | +): Promise<{ issues: Array<string>; source: string }> { |
| 77 | + const lines = source.split('\n'); |
| 78 | + const blockResults = []; |
| 79 | + const updates: Array<LineUpdate> = []; |
| 80 | + |
| 81 | + for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { |
| 82 | + if (!lines[lineIndex].includes('/**')) { |
| 83 | + continue; |
| 84 | + } |
| 85 | + |
| 86 | + const blockStart = lineIndex; |
| 87 | + while (lineIndex < lines.length && !lines[lineIndex].includes('*/')) { |
| 88 | + lineIndex++; |
| 89 | + } |
| 90 | + |
| 91 | + if (lineIndex === lines.length) { |
| 92 | + break; |
| 93 | + } |
| 94 | + |
| 95 | + const blockEnd = lineIndex; |
| 96 | + if (hasExampleTag(lines, blockStart, blockEnd)) { |
| 97 | + blockResults.push(prettifyBlock(filePath, lines, blockStart, blockEnd)); |
| 98 | + } |
| 99 | + } |
| 100 | + |
| 101 | + const fileIssues = []; |
| 102 | + for (const result of await Promise.all(blockResults)) { |
| 103 | + fileIssues.push(...result.issues); |
| 104 | + updates.push(...result.updates); |
| 105 | + } |
| 106 | + |
| 107 | + for (const update of updates.reverse()) { |
| 108 | + lines.splice(update.start, update.end - update.start + 1, ...update.lines); |
| 109 | + } |
| 110 | + |
| 111 | + return { issues: fileIssues, source: lines.join('\n') }; |
| 112 | +} |
| 113 | + |
| 114 | +async function prettifyBlock( |
| 115 | + filePath: string, |
| 116 | + lines: ReadonlyArray<string>, |
| 117 | + blockStart: number, |
| 118 | + blockEnd: number, |
| 119 | +): Promise<{ issues: Array<string>; updates: Array<LineUpdate> }> { |
| 120 | + const fenceResults: Array< |
| 121 | + Promise<{ issues: Array<string>; update?: LineUpdate }> |
| 122 | + > = []; |
| 123 | + let currentTag; |
| 124 | + |
| 125 | + for (let lineIndex = blockStart + 1; lineIndex < blockEnd; lineIndex++) { |
| 126 | + const line = jsdocLine(lines[lineIndex]); |
| 127 | + if (line == null) { |
| 128 | + continue; |
| 129 | + } |
| 130 | + |
| 131 | + const tag = /^@([^\s]+)/.exec(line.content.trimStart())?.[1]; |
| 132 | + if (tag != null) { |
| 133 | + currentTag = tag; |
| 134 | + } |
| 135 | + |
| 136 | + const fence = /^\s*```([^\s`]*)\s*(.*)$/.exec(line.content); |
| 137 | + if (fence == null) { |
| 138 | + continue; |
| 139 | + } |
| 140 | + |
| 141 | + const fenceEnd = closingFenceLine(lines, lineIndex + 1, blockEnd); |
| 142 | + if (fenceEnd == null) { |
| 143 | + fenceResults.push( |
| 144 | + Promise.resolve({ |
| 145 | + issues: [ |
| 146 | + formatIssue(filePath, lineIndex, 'Unclosed JSDoc example fence.'), |
| 147 | + ], |
| 148 | + }), |
| 149 | + ); |
| 150 | + break; |
| 151 | + } |
| 152 | + |
| 153 | + const language = fence[1].toLowerCase(); |
| 154 | + const extension = languageExtensions.get(language); |
| 155 | + const metadata = fence[2]; |
| 156 | + if ( |
| 157 | + currentTag === 'example' && |
| 158 | + extension != null && |
| 159 | + !metadata.includes('prettier-ignore') |
| 160 | + ) { |
| 161 | + fenceResults.push( |
| 162 | + prettifyFence(filePath, lines, lineIndex, fenceEnd, extension), |
| 163 | + ); |
| 164 | + } |
| 165 | + |
| 166 | + lineIndex = fenceEnd; |
| 167 | + } |
| 168 | + |
| 169 | + const blockIssues = []; |
| 170 | + const updates: Array<LineUpdate> = []; |
| 171 | + for (const result of await Promise.all(fenceResults)) { |
| 172 | + blockIssues.push(...result.issues); |
| 173 | + if (result.update != null) { |
| 174 | + updates.push(result.update); |
| 175 | + } |
| 176 | + } |
| 177 | + |
| 178 | + return { issues: blockIssues, updates }; |
| 179 | +} |
| 180 | + |
| 181 | +async function prettifyFence( |
| 182 | + filePath: string, |
| 183 | + lines: ReadonlyArray<string>, |
| 184 | + fenceStart: number, |
| 185 | + fenceEnd: number, |
| 186 | + extension: string, |
| 187 | +): Promise<{ issues: Array<string>; update?: LineUpdate }> { |
| 188 | + const codeLines = []; |
| 189 | + const contentPrefix = jsdocLine(lines[fenceStart])?.prefix ?? ' * '; |
| 190 | + |
| 191 | + for (let lineIndex = fenceStart + 1; lineIndex < fenceEnd; lineIndex++) { |
| 192 | + const line = jsdocLine(lines[lineIndex]); |
| 193 | + codeLines.push(line?.content ?? lines[lineIndex]); |
| 194 | + } |
| 195 | + |
| 196 | + const code = codeLines.join('\n'); |
| 197 | + let formatted; |
| 198 | + try { |
| 199 | + formatted = (await prettify(`example.${extension}`, code)).trimEnd(); |
| 200 | + } catch (error) { |
| 201 | + return { |
| 202 | + issues: [ |
| 203 | + formatIssue( |
| 204 | + filePath, |
| 205 | + fenceStart, |
| 206 | + `Could not prettify ${extension} example: ${errorMessage(error)}`, |
| 207 | + ), |
| 208 | + ], |
| 209 | + }; |
| 210 | + } |
| 211 | + |
| 212 | + if (formatted === code) { |
| 213 | + return { issues: [] }; |
| 214 | + } |
| 215 | + |
| 216 | + if (mode === 'check') { |
| 217 | + return { |
| 218 | + issues: [ |
| 219 | + formatIssue(filePath, fenceStart, 'JSDoc example is not formatted.'), |
| 220 | + ], |
| 221 | + }; |
| 222 | + } |
| 223 | + |
| 224 | + return { |
| 225 | + issues: [], |
| 226 | + update: { |
| 227 | + end: fenceEnd - 1, |
| 228 | + lines: formatted |
| 229 | + .split('\n') |
| 230 | + .map((line) => commentLine(contentPrefix, line)), |
| 231 | + start: fenceStart + 1, |
| 232 | + }, |
| 233 | + }; |
| 234 | +} |
| 235 | + |
| 236 | +function hasExampleTag( |
| 237 | + lines: ReadonlyArray<string>, |
| 238 | + blockStart: number, |
| 239 | + blockEnd: number, |
| 240 | +): boolean { |
| 241 | + for (let lineIndex = blockStart + 1; lineIndex < blockEnd; lineIndex++) { |
| 242 | + const line = jsdocLine(lines[lineIndex]); |
| 243 | + if (line?.content.trimStart().startsWith('@example')) { |
| 244 | + return true; |
| 245 | + } |
| 246 | + } |
| 247 | + return false; |
| 248 | +} |
| 249 | + |
| 250 | +function closingFenceLine( |
| 251 | + lines: ReadonlyArray<string>, |
| 252 | + start: number, |
| 253 | + blockEnd: number, |
| 254 | +): number | undefined { |
| 255 | + for (let lineIndex = start; lineIndex < blockEnd; lineIndex++) { |
| 256 | + if (jsdocLine(lines[lineIndex])?.content.trim() === '```') { |
| 257 | + return lineIndex; |
| 258 | + } |
| 259 | + } |
| 260 | +} |
| 261 | + |
| 262 | +function jsdocLine(line: string): JsdocLine | undefined { |
| 263 | + const match = /^(\s*\*\s?)(.*)$/.exec(line); |
| 264 | + if (match == null) { |
| 265 | + return; |
| 266 | + } |
| 267 | + return { content: match[2], prefix: match[1] }; |
| 268 | +} |
| 269 | + |
| 270 | +function commentLine(prefix: string, content: string): string { |
| 271 | + return content === '' ? prefix.trimEnd() : prefix + content; |
| 272 | +} |
| 273 | + |
| 274 | +function formatIssue( |
| 275 | + filePath: string, |
| 276 | + lineIndex: number, |
| 277 | + message: string, |
| 278 | +): string { |
| 279 | + return `${path.relative(localRepoPath(), filePath)}:${lineIndex + 1}: ${message}`; |
| 280 | +} |
| 281 | + |
| 282 | +function errorMessage(error: unknown): string { |
| 283 | + return error instanceof Error ? error.message.split('\n')[0] : String(error); |
| 284 | +} |
| 285 | + |
| 286 | +function* sourceFiles(dirPath: string): Generator<string> { |
| 287 | + const entries = fs.readdirSync(dirPath, { withFileTypes: true }); |
| 288 | + |
| 289 | + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { |
| 290 | + const entryPath = path.join(dirPath, entry.name); |
| 291 | + if (entry.isDirectory()) { |
| 292 | + yield* sourceFiles(entryPath); |
| 293 | + } else if (entry.name.endsWith('.ts')) { |
| 294 | + yield entryPath; |
| 295 | + } |
| 296 | + } |
| 297 | +} |
0 commit comments