-
Notifications
You must be signed in to change notification settings - Fork 8
/
Copy pathcode-stats.ts
318 lines (265 loc) · 8.71 KB
/
code-stats.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
312
313
314
315
316
317
318
#! /usr/bin/env node
"use strict"
// code-stats.js
// Show code statistics for your project.
import fs from "fs"
import globby from "globby"
// @ts-ignore
import packageJson from "./package.json"
// Debug off by default.
let isDebug = false
// Paths defaults to all visible in current dir.
const DEFAULT_PATHS = ['*']
// Pattern of filenames to exclude.
const DEFAULT_EXCLUDE = 'node_modules|jquery|underscore|mustache.js|require|order.js|text.js'
// Table output configuration.
const TABLE = {
HEADERS: ["Type", "Files", "Lines"],
COLUMN_ALIGN: "left" as const,
// Markdown-style delimiters (see: https://www.markdownguide.org/extended-syntax/#tables)
COLUMN_SEPARATOR: " | ",
DIVIDER_COLUMN_SEPARATOR: "-|-",
DIVIDER: "-"
};
// Default filetypes menu.
const TYPES_MENU = `
# Top languages (Github)
js coffee # Javascript
rb erb # Ruby
py # Python
sh # Shell
java # Java
php # Php
c h # C
cpp cc cxx # C++
pl pm t ep # Perl
m mm # Objective-C
ts # TypeScript
swift # Swift
scala # Scala
# More languages
asm # Assembly
clj # Clojure
go # Go
lisp # Lisp
hs # Haskell
pde # Processing
scm # Scheme
proto # Protocol Buffers
# Web and docs
html htm xhtml xml # Markup
css # Styles
mustache haml jade # Templates
less sass scss styl # Preprocessed CSS
md markdown # Docs
# Config
cfg ini # Settings
json yml # Serialized
`
// Filetypes to count.
const DEFAULT_TYPES = parseTypesMenu(TYPES_MENU)
function parseTypesMenu(menu: string) {
return menu
.replace(/\#.*/g, '')
.trim()
.split(/\s+/g)
}
function log(message: string) {
console.log(message)
}
function debug(...args: any[]) {
if (isDebug)
console.debug(...args)
}
function showVersion() {
let message = `
code-stats ${packageJson.version}
`
log(message)
}
function showHelp() {
let message = `
Usage: code-stats [options] [<paths>]
<paths> Paths to search; defaults to '*'.
Options:
-a, --all Include files of all types.
-d, --debug Debug options, files, and counts.
-h, --help Show this help info.
-t, --types <extensions> File extensions to search, along with defaults (space separated list).
-T, --types-only <extensions> File extensions to search, instead of defaults (space separated list).
-x, --exclude <pattern> Exclude files, along with defaults (regex).
-X, --exclude-only <pattern> Exclude files, instead of defaults (regex).
-v, --version Show the code-stats version.
`
log(message)
}
interface CodeStats {
totalFiles: number
totalLines: number
linesByType: Record<string, number>
filesByType: Record<string, number>
}
async function collectCodeStats({ paths, types, exclude, useDefaultExclude }: Options): Promise<CodeStats> {
let totalLines = 0
const filesByType: Record<string, number> = {}
const linesByType: Record<string, number> = {}
const filenames = findSourceFiles(paths, types, exclude, useDefaultExclude)
for (const filename of filenames) {
const { type, lines } = await collectSourceFileCodeStats(filename)
totalLines += lines
filesByType[type] = (filesByType[type] || 0) + 1
linesByType[type] = (linesByType[type] || 0) + lines
}
const totalFiles = filenames.length;
return {
totalFiles,
totalLines,
filesByType,
linesByType
}
}
async function collectSourceFileCodeStats(filename: string) {
const type = filename.replace(/^(.*)\.([^.]+)$/, '$2')
const lines = await countLines(filename)
debug(filename, type, lines)
return {
type,
lines
}
}
function findFilesInPaths(paths: string[]) {
const options = { expandDirectories: true }
const filenames = globby.sync(paths, options)
return filenames
}
function findSourceFiles(paths: string[], types: string[], exclude: string | null, useDefaultExclude: boolean) {
const filenames = findFilesInPaths(paths)
const typesRegex = new RegExp(`\\.(${types.join("|")})$`)
const excludeRegex = exclude && new RegExp(`(${exclude})`)
const defaultExcludeRegex = useDefaultExclude && new RegExp(`(${DEFAULT_EXCLUDE})`)
const matches = filenames
.filter(name => typesRegex.test(name))
.filter(name => excludeRegex ? !excludeRegex.test(name) : true)
.filter(name => defaultExcludeRegex ? !defaultExcludeRegex.test(name) : true)
debug("Ignored files:", filenames.length - matches.length)
return matches
}
const RETURN = "\r".charCodeAt(0)
const NEWLINE = "\n".charCodeAt(0)
async function countLines(file: string) {
let promise = new Promise<number>((resolve, reject) => {
let count = 0
let stream = fs.createReadStream(file)
.on('data', (chunk) => {
for (let i = 0; i < chunk.length; i++) {
let curr = chunk[i]
let next = i+1 < chunk.length ? chunk[i+1] : null
if (curr == RETURN || curr == NEWLINE) count++
if (curr == RETURN && next == NEWLINE) i++
}
})
.on('end', () =>
resolve(count))
})
return promise
}
function printResults({ linesByType, filesByType, totalLines, totalFiles }: CodeStats) {
const sortedRows = Object
.keys(linesByType)
.map(type => ({
type,
files: filesByType[type],
lines: linesByType[type]
}))
.sort((a, b) =>
a.lines != b.lines
? a.lines - b.lines
: a.type < b.type ? -1 : +1)
.reverse()
const formattedRows = sortedRows.map(row => [
row.type,
`${row.files}`,
`${row.lines} (${formatPercent(row.lines / totalLines)})`,
])
const summary = ["All", `${totalFiles}`, `${totalLines}`]
log("")
printTable(TABLE.HEADERS, formattedRows, summary)
}
function formatPercent(fraction: number) {
const n = fraction * 100
return `${n.toFixed(n >= 1 ? 0 : 1)}%`
}
function printTable(headers: string[], rows: string[][], footer: string[]) {
const columnWidths = calculateColumnWidths([headers, ...rows, footer])
printRow(headers, columnWidths)
printDivider(columnWidths)
rows.forEach(row => printRow(row, columnWidths))
printDivider(columnWidths)
printRow(footer, columnWidths)
}
function calculateColumnWidths(allRows: string[][]) {
const widths: number[] = []
allRows.forEach(row => {
row.forEach((col, i) => {
widths[i] = Math.max(widths[i] || 0, col.length)
})
})
return widths
}
function printRow(values: string[], widths: number[]) {
log(values.map((val, i) => padColumn(val, widths[i])).join(TABLE.COLUMN_SEPARATOR).trim())
}
function printDivider(widths: number[]) {
log(widths.map(width => repeat(TABLE.DIVIDER, width)).join(TABLE.DIVIDER_COLUMN_SEPARATOR))
}
function padColumn(text: string, width: number, align: "left" | "right" = TABLE.COLUMN_ALIGN) {
const pad = repeat(" ", width)
return align == "left"
? `${text}${pad}`.slice(0, pad.length)
: `${pad}${text}`.slice(-pad.length)
}
function repeat(char: string, length: number) {
return Array.from({ length }, () => char).join("")
}
interface Options {
types: string[]
exclude: null | string
useDefaultExclude: boolean
paths: string[]
}
function getOpts(): Options | false {
const opts: Options = {
types: [...DEFAULT_TYPES],
exclude: null,
useDefaultExclude: true,
paths: []
}
for (let i = 2; i < process.argv.length; i++) {
const arg = process.argv[i]
switch (arg) {
case '-a': case '--all': { opts.types = ['.*']; break }
case '-d': case '--debug': { isDebug = true; break }
case '-h': case '--help': { showHelp(); return false }
case '-v': case '--version': { showVersion(); return false }
case '-t': case '--types': { opts.types.push(...process.argv[i + 1].split(' ')); i++; break }
case '-T': case '--types-only': { opts.types = process.argv[i + 1].split(' '); i++; break }
case '-x': case '--exclude': { opts.exclude = process.argv[i + 1]; i++; break }
case '-X': case '--exclude-only': { opts.exclude = process.argv[i + 1]; opts.useDefaultExclude = false; i++; break }
default: { opts.paths.push(process.argv[i]) }
}
}
// Use default search paths if none given.
if (opts.paths.length == 0)
opts.paths = DEFAULT_PATHS
debug(JSON.stringify(opts, null, 2))
return opts
}
async function main() {
const opts = getOpts()
if (opts) {
const results = await collectCodeStats(opts)
printResults(results)
}
}
// Let's do this!
main()