-
Notifications
You must be signed in to change notification settings - Fork 5
/
index.ts
311 lines (292 loc) · 9.46 KB
/
index.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
#!/usr/bin/env node
import path from 'node:path'
import { readFile, rm, writeFile } from 'node:fs/promises'
import cac from 'cac'
import ora from 'ora'
import chalk from 'chalk'
import inquirer from 'inquirer'
import inquirerFileTreeSelection from 'inquirer-file-tree-selection-prompt'
import jsonc from 'jsonc-parser'
import { execaCommand } from 'execa'
import packageJSON from '../package.json' assert { type: 'json' }
inquirer.registerPrompt('file-tree-selection', inquirerFileTreeSelection)
const newLineRegExp = /\r?\n/
const errCodeRegExp = /error TS(?<errCode>\d+)/
const cli = cac('tsc-err-dirs')
const tscSpinner = ora(chalk.yellow('Start tsc compiling ...'))
type RawErrsMap = Map<string, TscErrorInfo[]>
interface TscErrorInfo {
filePath: string
errCode: number
errMsg: string
line: number
col: number
}
interface RootAndTarget {
root: string
targetAbsPath: string
}
function printTipsForFileChanged() {
console.log(
`\n💡 ${chalk.yellow(
"Tips: If you changed these 'error-files' while reading errors count below, the data may be not correct."
)}\n`
)
}
function isFilePath(absPath: string) {
return (absPath.split(path.sep).pop()?.split('.').length ?? 0) > 1
}
function getRawErrsSumCount(rawErrsMap: RawErrsMap) {
return [...rawErrsMap.values()].reduce((prev, next) => {
return (prev += next.length)
}, 0)
}
function getTargetDir(dirArg: string): string {
if (!dirArg) {
throw new Error("You didn't give a directory path")
}
const targetDir = dirArg.startsWith('.')
? path.join(process.cwd(), dirArg)
: dirArg.startsWith('/')
? dirArg
: (() => {
throw new Error('invalid directory path')
})()
return targetDir
}
function makeTscErrorInfo(errInfo: string): [string, TscErrorInfo] {
const panicMsg = 'failed to parsing error info.'
const [errFilePathPos = '', ...errMsgRawArr] = errInfo.split(':')
if (
!errFilePathPos ||
errMsgRawArr.length === 0 ||
errMsgRawArr.join('').length === 0
)
throw new Error(`${panicMsg} (on first split)`)
const errMsgRaw = errMsgRawArr.join('').trim()
// get filePath, line, col
const [errFilePath, errPos] = errFilePathPos
.slice(0, -1) // removes the ')'
.split('(')
if (!errFilePath || !errPos)
throw new Error(`${panicMsg} (on \`errFilePath\` or \`errPos\`)`)
const [errLine, errCol] = errPos.split(',')
if (!errLine || !errCol)
throw new Error(`${panicMsg} (on \`errLine\` or \`errCol\`)`)
// get errCode, errMsg
const execArr = errCodeRegExp.exec(errMsgRaw)
if (!execArr) throw new Error(`${panicMsg} (on \`errMsgRegExp.exec\`)`)
const { errCode = '' } = execArr.groups ?? {}
if (!errCode) throw new Error(`${panicMsg} (on \`errCode\`)`)
return [
errFilePath,
{
filePath: errFilePath,
line: Number(errLine),
col: Number(errCol),
errCode: Number(errCode),
errMsg: errMsgRaw.slice(`error TS${errCode}`.length - 1),
},
]
}
function showFileErrs(options: {
selectedPath: string
rootAbsPath: string
rawAllErrsMap: RawErrsMap
}) {
const { selectedPath, rootAbsPath, rawAllErrsMap } = options
const foundErrsByFilePath =
[...rawAllErrsMap.entries()].find(([relativeToRoot]) =>
path.join(rootAbsPath, relativeToRoot).includes(selectedPath)
)?.[1] ?? []
const selectedFileErrsStdout = foundErrsByFilePath
.map((errInfo) => {
return `
${chalk.bold.red(
`${path.join(rootAbsPath, errInfo.filePath)}(${errInfo.line},${errInfo.col})`
)}
${chalk.blue(`error TS${errInfo.errCode}`)}: ${errInfo.errMsg}
`
})
.join('\n')
console.log(selectedFileErrsStdout)
}
async function getTscCompileStdout(
// The `cwd` dir requires an existing `tsconfig.json` file
options: RootAndTarget & {
pretty?: boolean
}
) {
const { root = process.cwd(), pretty = false, targetAbsPath = root } = options
const baseConfigPath = path.join(root, 'tsconfig.json')
const baseConfigJSON = jsonc.parse(String(await readFile(baseConfigPath)))
const tmpConfigPath = path.join(root, 'tsconfig.tmp.json')
// Use a temp tsconfig
try {
const tmpTsConfig: Record<string, any> = { ...baseConfigJSON }
// Avoid conflict with --noEmit
tmpTsConfig.compilerOptions.emitDeclarationOnly = false
if (isFilePath(targetAbsPath)) {
tmpTsConfig.files = [targetAbsPath]
}
const tsconfigFinalContent = JSON.stringify(tmpTsConfig, null, 2)
await writeFile(tmpConfigPath, tsconfigFinalContent)
} catch (err) {
console.log(
`${chalk.red('Failed to process `tsconfig.json`')}\n${chalk.red(err)}`
)
process.exit()
}
let tscErrorStdout = ''
try {
const cmd = `tsc --noEmit --pretty ${pretty} -p ${tmpConfigPath}`
console.log(
`\n$ ${chalk.yellowBright(root)}\n ${chalk.bold.gray(
`tsc running on ${chalk.bold.blue(targetAbsPath)} ...`
)}\n ${chalk.grey(`▷ ${cmd}`)}\n`
)
tscSpinner.start()
const tscProcess = execaCommand(cmd, {
cwd: root,
stdout: 'pipe',
reject: false,
})
tscProcess.stdout?.on('data', (errInfoChunk) => {
tscErrorStdout += errInfoChunk
})
await tscProcess
tscSpinner.succeed(chalk.yellow('tsc compiling finished.'))
await rm(tmpConfigPath, { force: true })
} catch (err) {
tscSpinner.succeed(chalk.yellow('tsc compiling failed.'))
console.log(chalk.red(`Error: ${err}`))
}
return tscErrorStdout
}
async function getRawErrsMap(options: RootAndTarget) {
const tscErrorStdout = await getTscCompileStdout(options)
const rawErrsMap: RawErrsMap = new Map()
// Merge details line with main line (i.e. which contains file path)
tscErrorStdout
.split(newLineRegExp)
.reduce<string[]>((prev, next) => {
if (!next) {
return prev
} else if (!next.startsWith(' ')) {
prev.push(next)
} else {
prev[prev.length - 1] += `\n${next}`
}
return prev
}, [])
.map((errInfoLine) => makeTscErrorInfo(errInfoLine))
.forEach(([errFilePath, errInfo]) => {
if (!rawErrsMap.has(errFilePath)) {
rawErrsMap.set(errFilePath, [errInfo])
} else {
rawErrsMap.get(errFilePath)?.push(errInfo)
}
})
return rawErrsMap
}
async function selectFile(
options: RootAndTarget & {
rawErrsMap: RawErrsMap
}
) {
const { root, targetAbsPath, rawErrsMap } = options
const errsCountNumLength = String(getRawErrsSumCount(rawErrsMap)).length
const isOptionPathContained =
(optionPath: string) => (relativeToRoot: string) => {
const absPath = path.join(root, relativeToRoot)
return absPath.includes(optionPath)
}
const optionPathTransformer = (optionPath: string) => {
if (optionPath === targetAbsPath) {
return chalk.yellowBright(`root: ${targetAbsPath}`)
}
const optionPathLastUnit = optionPath.split(path.sep).pop() ?? ''
const optionPathIsFilePath = isFilePath(optionPathLastUnit)
const colorFn = optionPathIsFilePath
? chalk.blue
: chalk.italic.bold.yellowBright
const errsCountInPath = [...rawErrsMap.keys()]
.filter(isOptionPathContained(optionPath))
.reduce((prev, hasErrPath) => {
return prev + (rawErrsMap.get(hasErrPath)?.length ?? 0)
}, 0)
return `${chalk.bold.redBright(
`${String(errsCountInPath).padStart(errsCountNumLength)} errors`
)} ${colorFn(optionPathLastUnit + (optionPathIsFilePath ? '' : '/'))}`
}
const selectedFilePath = await inquirer.prompt([
{
type: 'file-tree-selection',
name: 'file',
message: 'select file to show error details',
pageSize: 20,
root: targetAbsPath, // this `root` property is different, it's used for display a directory's file tree
// Maybe some tsc errors are out of this root
onlyShowValid: true,
validate: (optionPath: string) => {
const hasErrilesUnderRoot = [...rawErrsMap.keys()].some(
isOptionPathContained(optionPath)
)
return hasErrilesUnderRoot
},
transformer: optionPathTransformer,
},
])
return selectedFilePath?.file ?? ''
}
try {
console.log(
`\n${chalk.bold.blue(`
_____ _____ ____ _
|_ _|__ ___| ____|_ __ _ __| _ \\(_)_ __ ___
| |/ __|/ __| _| | '__| '__| | | | | '__/ __|
| |\\__ \\ (__| |___| | | | | |_| | | | \\__ \\
|_||___/\\___|_____|_| |_| |____/|_|_| |___/ ${chalk.cyanBright(
`[version: v${packageJSON.version}]`
)}
`)}`
)
const parsedEnvArgs = cli.parse()
const rootDirArg = parsedEnvArgs.args[0]
const rootAbsPath = getTargetDir(rootDirArg)
const baseRootAndTarget = {
root: rootAbsPath,
targetAbsPath: rootAbsPath,
}
// Generate a map to store errors info
const rawAllErrsMap = await getRawErrsMap(baseRootAndTarget)
if (getRawErrsSumCount(rawAllErrsMap) === 0) {
console.log(`\n🎉 ${chalk.bold.greenBright('Found 0 Errors.')}\n`)
process.exit()
}
printTipsForFileChanged()
let selectedPath = rootAbsPath
do {
// Aggregation by file path and make an interactive view to select
selectedPath = await selectFile({
root: rootAbsPath,
targetAbsPath: selectedPath,
rawErrsMap: rawAllErrsMap,
})
if (!selectedPath) {
throw new Error('failed to select file!')
}
if (isFilePath(selectedPath)) {
// show selected file's pretty tsc errors information
showFileErrs({
selectedPath,
rootAbsPath,
rawAllErrsMap,
})
selectedPath = rootAbsPath
}
// eslint-disable-next-line no-constant-condition
} while (true)
} catch (err) {
console.log(chalk.red(`\n${err}`))
}