@h5l0/codelens 0.1.0
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.
- package/LICENSE +21 -0
- package/README.en.md +137 -0
- package/README.md +137 -0
- package/dist/cli/args.js +126 -0
- package/dist/cli/dev.js +33 -0
- package/dist/cli/index.js +163 -0
- package/dist/cli/paths.js +11 -0
- package/dist/cli/server.js +165 -0
- package/dist/core/calendar.js +178 -0
- package/dist/core/gitignore.js +65 -0
- package/dist/core/glob.js +196 -0
- package/dist/core/loc.js +107 -0
- package/dist/core/profile.js +363 -0
- package/dist/core/scan.js +125 -0
- package/dist/core/types.js +5 -0
- package/dist/core/util.js +8 -0
- package/dist/web/assets/index-B4yfgYFQ.css +1 -0
- package/dist/web/assets/index-BIIwVDXB.js +9 -0
- package/dist/web/index.html +14 -0
- package/docs/screenshots/en/calendar.png +0 -0
- package/docs/screenshots/en/loc.png +0 -0
- package/docs/screenshots/zh/calendar.png +0 -0
- package/docs/screenshots/zh/loc.png +0 -0
- package/package.json +66 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// 本地托管
|
|
3
|
+
// 用 node 内置 http 提供静态页面与两份数据接口,API 中间件同时供 Vite 开发服务器复用。
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
import { createReadStream, statSync } from 'node:fs';
|
|
6
|
+
import { createServer } from 'node:http';
|
|
7
|
+
import { extname, join, resolve, sep } from 'node:path';
|
|
8
|
+
const MIME = {
|
|
9
|
+
'.html': 'text/html; charset=utf-8',
|
|
10
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
11
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
12
|
+
'.css': 'text/css; charset=utf-8',
|
|
13
|
+
'.json': 'application/json; charset=utf-8',
|
|
14
|
+
'.svg': 'image/svg+xml',
|
|
15
|
+
'.png': 'image/png',
|
|
16
|
+
'.jpg': 'image/jpeg',
|
|
17
|
+
'.ico': 'image/x-icon',
|
|
18
|
+
'.woff2': 'font/woff2',
|
|
19
|
+
'.wasm': 'application/wasm',
|
|
20
|
+
'.map': 'application/json; charset=utf-8',
|
|
21
|
+
};
|
|
22
|
+
function sendJson(res, body) {
|
|
23
|
+
res.writeHead(200, {
|
|
24
|
+
'content-type': 'application/json; charset=utf-8',
|
|
25
|
+
'content-length': Buffer.byteLength(body),
|
|
26
|
+
// 数据每次启动重新生成,禁用缓存避免看到旧结果
|
|
27
|
+
'cache-control': 'no-store',
|
|
28
|
+
});
|
|
29
|
+
res.end(body);
|
|
30
|
+
}
|
|
31
|
+
function sendText(res, status, body) {
|
|
32
|
+
res.writeHead(status, { 'content-type': 'text/plain; charset=utf-8' });
|
|
33
|
+
res.end(body);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* 取出请求的路径。畸形 URL(例如 `/%`)只该让这一个请求失败,
|
|
37
|
+
* 不能让整个进程崩掉,所以这里返回 undefined 交给调用方回 400。
|
|
38
|
+
*/
|
|
39
|
+
function pathnameOf(req) {
|
|
40
|
+
try {
|
|
41
|
+
return new URL(req.url ?? '/', 'http://localhost').pathname;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** 数据接口:/api/data.json 是改动日历,/api/loc.json 是行数清单。 */
|
|
48
|
+
export function createApiMiddleware(payloads) {
|
|
49
|
+
return (req, res, next) => {
|
|
50
|
+
const pathname = pathnameOf(req);
|
|
51
|
+
if (pathname === undefined) {
|
|
52
|
+
sendText(res, 400, 'bad request');
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (pathname === '/api/data.json' || pathname === '/data.json') {
|
|
56
|
+
sendJson(res, payloads.data);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (pathname === '/api/loc.json' || pathname === '/loc.json') {
|
|
60
|
+
sendJson(res, payloads.loc);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
next();
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/** 静态资源:把构建产物目录当作站点根。 */
|
|
67
|
+
export function createStaticMiddleware(webDir) {
|
|
68
|
+
return (req, res, next) => {
|
|
69
|
+
const rawPath = pathnameOf(req);
|
|
70
|
+
if (rawPath === undefined) {
|
|
71
|
+
sendText(res, 400, 'bad request');
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
let pathname;
|
|
75
|
+
try {
|
|
76
|
+
pathname = decodeURIComponent(rawPath);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
sendText(res, 400, 'bad request: malformed percent-encoding in the url');
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const rel = pathname === '/' ? 'index.html' : pathname.replace(/^\/+/, '');
|
|
83
|
+
let filePath = resolve(webDir, rel);
|
|
84
|
+
if (filePath !== webDir && !filePath.startsWith(webDir + sep)) {
|
|
85
|
+
sendText(res, 403, 'path escapes the site root');
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
if (statSync(filePath).isDirectory()) {
|
|
90
|
+
filePath = join(filePath, 'index.html');
|
|
91
|
+
statSync(filePath);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
next();
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const { size } = statSync(filePath);
|
|
99
|
+
res.writeHead(200, {
|
|
100
|
+
'content-type': MIME[extname(filePath).toLowerCase()] ?? 'application/octet-stream',
|
|
101
|
+
'content-length': size,
|
|
102
|
+
'cache-control': 'no-store',
|
|
103
|
+
});
|
|
104
|
+
if (req.method === 'HEAD') {
|
|
105
|
+
res.end();
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
createReadStream(filePath).pipe(res);
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
export function compose(middlewares) {
|
|
112
|
+
return (req, res, next) => {
|
|
113
|
+
let index = 0;
|
|
114
|
+
const step = () => {
|
|
115
|
+
const middleware = middlewares[index];
|
|
116
|
+
index += 1;
|
|
117
|
+
if (!middleware) {
|
|
118
|
+
next();
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
middleware(req, res, step);
|
|
122
|
+
};
|
|
123
|
+
step();
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1', '[::1]']);
|
|
127
|
+
/** 是否只有本机能访问;否则页面对同网段的机器也是开放的。 */
|
|
128
|
+
export function isLoopbackHost(host) {
|
|
129
|
+
return LOOPBACK_HOSTS.has(host);
|
|
130
|
+
}
|
|
131
|
+
/** 打印用的地址:监听全部网卡时换成本机名,IPv6 补上方括号。 */
|
|
132
|
+
export function displayHost(host) {
|
|
133
|
+
if (host === '0.0.0.0' || host === '::' || host === '[::]') {
|
|
134
|
+
return 'localhost';
|
|
135
|
+
}
|
|
136
|
+
return host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
|
|
137
|
+
}
|
|
138
|
+
export function startServer(middleware, opts) {
|
|
139
|
+
const handler = compose([middleware]);
|
|
140
|
+
const server = createServer((req, res) => {
|
|
141
|
+
handler(req, res, () => sendText(res, 404, `not found: ${req.url ?? '/'}`));
|
|
142
|
+
});
|
|
143
|
+
const listen = (port, left) => new Promise((done, fail) => {
|
|
144
|
+
const onError = (err) => {
|
|
145
|
+
server.removeListener('error', onError);
|
|
146
|
+
if (err.code === 'EADDRINUSE' && left > 0) {
|
|
147
|
+
done(listen(port + 1, left - 1));
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
fail(err);
|
|
151
|
+
};
|
|
152
|
+
server.once('error', onError);
|
|
153
|
+
server.listen(port, opts.host, () => {
|
|
154
|
+
server.removeListener('error', onError);
|
|
155
|
+
// port 为 0 时由系统分配,要取真实端口,否则打印出来的地址打不开
|
|
156
|
+
const address = server.address();
|
|
157
|
+
done(typeof address === 'object' && address !== null ? address.port : port);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
return listen(opts.port, opts.attempts ?? 10).then((port) => ({
|
|
161
|
+
port,
|
|
162
|
+
url: `http://${displayHost(opts.host)}:${port}/`,
|
|
163
|
+
close: () => server.close(),
|
|
164
|
+
}));
|
|
165
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// 改动日历
|
|
3
|
+
// 用 git log --numstat 流式读出最近一段时间的提交,按文件的路径归属拆到各分组。
|
|
4
|
+
// 日期统一取 committer date,与 git log --since 的过滤口径一致;在子目录里运行时
|
|
5
|
+
// 加 --relative,路径与统计范围都和行数视图对齐。
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
8
|
+
import { createInterface } from 'node:readline';
|
|
9
|
+
import { firstMatch } from './glob.js';
|
|
10
|
+
import { createPathFilter, GIT_SAFE_CONFIG } from './scan.js';
|
|
11
|
+
import { repoName } from './util.js';
|
|
12
|
+
const COMMIT_PREFIX = 'COMMIT|';
|
|
13
|
+
function emptyStats() {
|
|
14
|
+
return { commits: 0, add: 0, del: 0 };
|
|
15
|
+
}
|
|
16
|
+
function blankGroups(groups) {
|
|
17
|
+
const out = { all: emptyStats() };
|
|
18
|
+
for (const group of groups) {
|
|
19
|
+
out[group.id] = emptyStats();
|
|
20
|
+
}
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
/** 目录是否在 git 仓库内,用于提前给出可读的提示。 */
|
|
24
|
+
export function isGitRepo(root) {
|
|
25
|
+
try {
|
|
26
|
+
const out = execFileSync('git', ['-C', root, ...GIT_SAFE_CONFIG, 'rev-parse', '--is-inside-work-tree'], {
|
|
27
|
+
encoding: 'utf8',
|
|
28
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
29
|
+
});
|
|
30
|
+
return out.trim() === 'true';
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** 逐行读 git 的输出:历史很大时不会像一次性读入那样撞上 maxBuffer。 */
|
|
37
|
+
async function streamLines(args, onLine) {
|
|
38
|
+
const child = spawn('git', args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
39
|
+
let stderr = '';
|
|
40
|
+
child.stderr.on('data', (chunk) => {
|
|
41
|
+
if (stderr.length < 4096) {
|
|
42
|
+
stderr += chunk.toString('utf8');
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
try {
|
|
46
|
+
const done = new Promise((resolve, reject) => {
|
|
47
|
+
child.on('error', reject);
|
|
48
|
+
child.on('close', resolve);
|
|
49
|
+
});
|
|
50
|
+
const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
|
|
51
|
+
for await (const line of lines) {
|
|
52
|
+
onLine(line);
|
|
53
|
+
}
|
|
54
|
+
const code = await done;
|
|
55
|
+
if (code !== 0) {
|
|
56
|
+
throw new Error(stderr.trim() || `git exited with code ${code}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
child.kill();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export async function buildCalendar(root, profile, opts) {
|
|
64
|
+
const args = ['-C', root, ...GIT_SAFE_CONFIG, '-c', 'core.quotePath=false', 'log'];
|
|
65
|
+
if (opts.days > 0) {
|
|
66
|
+
args.push(`--since=${opts.days} days ago`);
|
|
67
|
+
}
|
|
68
|
+
args.push('--relative', '--date=short', '--no-renames', `--pretty=format:${COMMIT_PREFIX}%cd|%h|%s`, '--numstat');
|
|
69
|
+
const counted = createPathFilter(profile.ignore);
|
|
70
|
+
const days = new Map();
|
|
71
|
+
let current = null;
|
|
72
|
+
let currentGroups = null;
|
|
73
|
+
let touched = false;
|
|
74
|
+
const flush = () => {
|
|
75
|
+
if (!current) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const { date, groups, ...rest } = current;
|
|
79
|
+
// 合并提交、空提交没有任何文件行,也要计入 all,
|
|
80
|
+
// 否则统计卡的提交数与下方提交列表对不上
|
|
81
|
+
if (!touched) {
|
|
82
|
+
groups.all.commits = 1;
|
|
83
|
+
}
|
|
84
|
+
const entry = { ...rest, groups };
|
|
85
|
+
const day = days.get(date) ?? { commits: [], groups: blankGroups(profile.groups) };
|
|
86
|
+
day.commits.push(entry);
|
|
87
|
+
for (const [id, stat] of Object.entries(groups)) {
|
|
88
|
+
const target = day.groups[id] ?? emptyStats();
|
|
89
|
+
target.commits += stat.commits;
|
|
90
|
+
target.add += stat.add;
|
|
91
|
+
target.del += stat.del;
|
|
92
|
+
day.groups[id] = target;
|
|
93
|
+
}
|
|
94
|
+
days.set(date, day);
|
|
95
|
+
current = null;
|
|
96
|
+
currentGroups = null;
|
|
97
|
+
touched = false;
|
|
98
|
+
};
|
|
99
|
+
await streamLines(args, (line) => {
|
|
100
|
+
if (line === '') {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (line.startsWith(COMMIT_PREFIX)) {
|
|
104
|
+
flush();
|
|
105
|
+
const parts = line.slice(COMMIT_PREFIX.length).split('|');
|
|
106
|
+
current = {
|
|
107
|
+
date: parts[0],
|
|
108
|
+
hash: parts[1],
|
|
109
|
+
subject: parts.slice(2).join('|'),
|
|
110
|
+
groups: blankGroups(profile.groups),
|
|
111
|
+
};
|
|
112
|
+
currentGroups = current.groups;
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const parts = line.split('\t');
|
|
116
|
+
if (parts.length < 3 || !current || !currentGroups) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const path = parts.slice(2).join('\t');
|
|
120
|
+
if (!counted(path)) {
|
|
121
|
+
// 与行数视图共用一套忽略口径,两个视图的分母才对得上
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const add = parts[0] === '-' ? 0 : Number.parseInt(parts[0], 10);
|
|
125
|
+
const del = parts[1] === '-' ? 0 : Number.parseInt(parts[1], 10);
|
|
126
|
+
if (Number.isNaN(add) || Number.isNaN(del)) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
touched = true;
|
|
130
|
+
const groupId = firstMatch(path, profile.groups);
|
|
131
|
+
for (const id of groupId ? ['all', groupId] : ['all']) {
|
|
132
|
+
const stat = currentGroups[id];
|
|
133
|
+
stat.add += add;
|
|
134
|
+
stat.del += del;
|
|
135
|
+
// 同一个提交里改多个文件只算一次;改动行数为 0(二进制、仅改权限)也算改过
|
|
136
|
+
stat.commits = 1;
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
flush();
|
|
140
|
+
const dates = [...days.keys()].sort();
|
|
141
|
+
const totals = blankGroups(profile.groups);
|
|
142
|
+
let maxVal = 1;
|
|
143
|
+
let maxCommits = 1;
|
|
144
|
+
for (const day of days.values()) {
|
|
145
|
+
for (const [id, stat] of Object.entries(day.groups)) {
|
|
146
|
+
totals[id].add += stat.add;
|
|
147
|
+
totals[id].del += stat.del;
|
|
148
|
+
totals[id].commits += stat.commits;
|
|
149
|
+
}
|
|
150
|
+
maxVal = Math.max(maxVal, day.groups.all.add, day.groups.all.del);
|
|
151
|
+
maxCommits = Math.max(maxCommits, day.groups.all.commits);
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
generatedAt: new Date().toISOString(),
|
|
155
|
+
root: repoName(root),
|
|
156
|
+
profile: profile.name,
|
|
157
|
+
groups: profile.groups.map(({ id, label, labelKey, hue, sat }) => ({ id, label, labelKey, hue, sat })),
|
|
158
|
+
range: { min: dates[0] ?? '', max: dates[dates.length - 1] ?? '' },
|
|
159
|
+
totals: { days: dates.length, groups: totals },
|
|
160
|
+
maxVal,
|
|
161
|
+
maxCommits,
|
|
162
|
+
days: Object.fromEntries(dates.map((date) => [date, days.get(date)])),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
/** git 不可用或目录不是仓库时的空日历,让页面的其余部分仍可用。 */
|
|
166
|
+
export function emptyCalendar(root, profile) {
|
|
167
|
+
return {
|
|
168
|
+
generatedAt: new Date().toISOString(),
|
|
169
|
+
root: repoName(root),
|
|
170
|
+
profile: profile.name,
|
|
171
|
+
groups: profile.groups.map(({ id, label, labelKey, hue, sat }) => ({ id, label, labelKey, hue, sat })),
|
|
172
|
+
range: { min: '', max: '' },
|
|
173
|
+
totals: { days: 0, groups: blankGroups(profile.groups) },
|
|
174
|
+
maxVal: 1,
|
|
175
|
+
maxCommits: 1,
|
|
176
|
+
days: {},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// .gitignore 解析与匹配
|
|
3
|
+
// git 可用时直接由 git 过滤,这里的实现用于 git 缺失或非仓库目录的情形。
|
|
4
|
+
// 语义与 git 一致:后出现的规则覆盖先出现的,`!` 表示反向排除,
|
|
5
|
+
// 结尾 `/` 只匹配目录,含 `/` 的模式从所在目录锚定。
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { createGlob } from './glob.js';
|
|
10
|
+
function parseLine(raw, base) {
|
|
11
|
+
// 行尾未转义的空格在 gitignore 里被忽略
|
|
12
|
+
const line = raw.replace(/(?<!\\)\s+$/, '');
|
|
13
|
+
if (line === '' || line.startsWith('#')) {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
const negate = line.startsWith('!');
|
|
17
|
+
let pattern = negate ? line.slice(1) : line;
|
|
18
|
+
// 反斜杠加空格是转义的空格,还原成普通空格
|
|
19
|
+
pattern = pattern.replace(/\\ /g, ' ');
|
|
20
|
+
const dirOnly = pattern.endsWith('/');
|
|
21
|
+
if (dirOnly) {
|
|
22
|
+
pattern = pattern.slice(0, -1);
|
|
23
|
+
}
|
|
24
|
+
if (pattern === '') {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
// 开头的 `/` 由 createGlob 解释为从所在目录起算,这里原样传下去
|
|
28
|
+
if (pattern === '') {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
return { base, negate, dirOnly, test: createGlob(pattern, base) };
|
|
32
|
+
}
|
|
33
|
+
/** 解析一段 .gitignore 文本,base 为该文件所在目录。 */
|
|
34
|
+
export function parseIgnoreText(text, base) {
|
|
35
|
+
const rules = [];
|
|
36
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
37
|
+
const rule = parseLine(raw, base);
|
|
38
|
+
if (rule) {
|
|
39
|
+
rules.push(rule);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return rules;
|
|
43
|
+
}
|
|
44
|
+
/** 读取目录下的 .gitignore,不存在时返回空数组。 */
|
|
45
|
+
export function loadIgnoreFile(dir, base) {
|
|
46
|
+
try {
|
|
47
|
+
return parseIgnoreText(readFileSync(join(dir, '.gitignore'), 'utf8'), base);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** 从后往前找第一条命中的规则,决定路径是否被忽略。 */
|
|
54
|
+
export function isIgnored(relPath, isDir, rules) {
|
|
55
|
+
for (let i = rules.length - 1; i >= 0; i -= 1) {
|
|
56
|
+
const rule = rules[i];
|
|
57
|
+
if (rule.dirOnly && !isDir) {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (rule.test(relPath)) {
|
|
61
|
+
return !rule.negate;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Glob 匹配
|
|
3
|
+
// 把仓库相对路径(posix 分隔)的 glob 编译成线性时间的匹配函数,分类、分组、
|
|
4
|
+
// 忽略规则共用。约定:独立的 `**` 段跨目录,`*` 不跨目录,`?` 匹配单个非分隔
|
|
5
|
+
// 字符,`{a,b}` 择一;不含 `/` 的模式匹配基准目录下任意层级的同名项,
|
|
6
|
+
// 以 `/` 开头表示从基准目录锚定,以 `/` 结尾表示目录及其全部内容。
|
|
7
|
+
//
|
|
8
|
+
// 匹配按路径分段做记忆化递推,不用回溯正则:`**a` 反复出现的恶意模式
|
|
9
|
+
// (会被一个仓库自带的配置或 .gitignore 带进来)不会让进程卡住。
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
/** 花括号展开的组合上限,超过就整体按字面量处理,避免配置撑爆内存。 */
|
|
12
|
+
const MAX_ALTERNATIVES = 256;
|
|
13
|
+
const cache = new Map();
|
|
14
|
+
const NEVER = () => false;
|
|
15
|
+
/** 找到与 open 处 `{` 配对的 `}`,考虑嵌套。 */
|
|
16
|
+
function findClose(text, open) {
|
|
17
|
+
let depth = 0;
|
|
18
|
+
for (let i = open; i < text.length; i += 1) {
|
|
19
|
+
if (text[i] === '{') {
|
|
20
|
+
depth += 1;
|
|
21
|
+
}
|
|
22
|
+
else if (text[i] === '}') {
|
|
23
|
+
depth -= 1;
|
|
24
|
+
if (depth === 0) {
|
|
25
|
+
return i;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return -1;
|
|
30
|
+
}
|
|
31
|
+
/** 按顶层逗号切分 `{a,{b,c}}` 的内容,嵌套的花括号不切。 */
|
|
32
|
+
function splitAlternatives(body) {
|
|
33
|
+
const parts = [];
|
|
34
|
+
let depth = 0;
|
|
35
|
+
let start = 0;
|
|
36
|
+
for (let i = 0; i < body.length; i += 1) {
|
|
37
|
+
const ch = body[i];
|
|
38
|
+
if (ch === '{') {
|
|
39
|
+
depth += 1;
|
|
40
|
+
}
|
|
41
|
+
else if (ch === '}') {
|
|
42
|
+
depth -= 1;
|
|
43
|
+
}
|
|
44
|
+
else if (ch === ',' && depth === 0) {
|
|
45
|
+
parts.push(body.slice(start, i));
|
|
46
|
+
start = i + 1;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
parts.push(body.slice(start));
|
|
50
|
+
return parts;
|
|
51
|
+
}
|
|
52
|
+
/** 展开 `{a,b}` 择一写法;没有花括号或展开过多时原样返回。 */
|
|
53
|
+
export function expandBraces(pattern) {
|
|
54
|
+
const out = [];
|
|
55
|
+
const walk = (text) => {
|
|
56
|
+
if (out.length > MAX_ALTERNATIVES) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const open = text.indexOf('{');
|
|
60
|
+
const close = open === -1 ? -1 : findClose(text, open);
|
|
61
|
+
if (open === -1 || close === -1) {
|
|
62
|
+
out.push(text);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const head = text.slice(0, open);
|
|
66
|
+
const tail = text.slice(close + 1);
|
|
67
|
+
for (const alt of splitAlternatives(text.slice(open + 1, close))) {
|
|
68
|
+
walk(`${head}${alt}${tail}`);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
walk(pattern);
|
|
72
|
+
return out.length > MAX_ALTERNATIVES ? [pattern] : out;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* 单个路径段的匹配:`*` 与 `?` 都不跨分隔符(段内本来也没有分隔符)。
|
|
76
|
+
* 用双指针加单星号回溯,最坏 O(段长 × 模式长),不会指数爆炸。
|
|
77
|
+
*/
|
|
78
|
+
function matchSegment(pattern, text) {
|
|
79
|
+
let p = 0;
|
|
80
|
+
let t = 0;
|
|
81
|
+
let star = -1;
|
|
82
|
+
let mark = 0;
|
|
83
|
+
while (t < text.length) {
|
|
84
|
+
const ch = pattern[p];
|
|
85
|
+
if (ch === '*') {
|
|
86
|
+
star = p;
|
|
87
|
+
mark = t;
|
|
88
|
+
p += 1;
|
|
89
|
+
}
|
|
90
|
+
else if (ch === '?' || (ch !== undefined && ch === text[t])) {
|
|
91
|
+
p += 1;
|
|
92
|
+
t += 1;
|
|
93
|
+
}
|
|
94
|
+
else if (star !== -1) {
|
|
95
|
+
p = star + 1;
|
|
96
|
+
mark += 1;
|
|
97
|
+
t = mark;
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
while (pattern[p] === '*') {
|
|
104
|
+
p += 1;
|
|
105
|
+
}
|
|
106
|
+
return p === pattern.length;
|
|
107
|
+
}
|
|
108
|
+
/** 逐段匹配,独立的 `**` 段吃掉任意多段路径。 */
|
|
109
|
+
function matchSegments(segments, path) {
|
|
110
|
+
const parts = path.split('/');
|
|
111
|
+
const n = segments.length;
|
|
112
|
+
const m = parts.length;
|
|
113
|
+
const memo = new Map();
|
|
114
|
+
const step = (i, j) => {
|
|
115
|
+
if (i === n) {
|
|
116
|
+
return j === m;
|
|
117
|
+
}
|
|
118
|
+
const key = i * (m + 1) + j;
|
|
119
|
+
const hit = memo.get(key);
|
|
120
|
+
if (hit !== undefined) {
|
|
121
|
+
return hit;
|
|
122
|
+
}
|
|
123
|
+
let out;
|
|
124
|
+
if (segments[i] === '**') {
|
|
125
|
+
out = step(i + 1, j) || (j < m && step(i, j + 1));
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
out = j < m && matchSegment(segments[i], parts[j]) && step(i + 1, j + 1);
|
|
129
|
+
}
|
|
130
|
+
memo.set(key, out);
|
|
131
|
+
return out;
|
|
132
|
+
};
|
|
133
|
+
return step(0, 0);
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* 编译一个 glob。base 为模式所锚定的目录(相对仓库根,根目录传空串),
|
|
137
|
+
* 通常用于 .gitignore:规则里的相对写法都从该文件所在目录起算。
|
|
138
|
+
*/
|
|
139
|
+
export function createGlob(pattern, base = '') {
|
|
140
|
+
const key = `${base}\u0000${pattern}`;
|
|
141
|
+
const hit = cache.get(key);
|
|
142
|
+
if (hit) {
|
|
143
|
+
return hit;
|
|
144
|
+
}
|
|
145
|
+
let body = pattern.startsWith('./') ? pattern.slice(2) : pattern;
|
|
146
|
+
const anchored = body.startsWith('/');
|
|
147
|
+
if (anchored) {
|
|
148
|
+
body = body.slice(1);
|
|
149
|
+
}
|
|
150
|
+
const dirOnly = body.endsWith('/');
|
|
151
|
+
if (dirOnly) {
|
|
152
|
+
body = body.slice(0, -1);
|
|
153
|
+
}
|
|
154
|
+
let matcher = NEVER;
|
|
155
|
+
if (body !== '') {
|
|
156
|
+
const baseSegments = base === '' ? [] : base.split('/');
|
|
157
|
+
// 不含 `/` 的模式可以出现在基准目录下的任意层级
|
|
158
|
+
const anywhere = !anchored && !body.includes('/');
|
|
159
|
+
const alternatives = expandBraces(body).map((variant) => {
|
|
160
|
+
const segments = [...baseSegments];
|
|
161
|
+
if (anywhere) {
|
|
162
|
+
segments.push('**');
|
|
163
|
+
}
|
|
164
|
+
segments.push(...variant.split('/').filter((seg) => seg !== ''));
|
|
165
|
+
if (dirOnly) {
|
|
166
|
+
// 目录本身与它下面的一切都算命中
|
|
167
|
+
segments.push('**');
|
|
168
|
+
}
|
|
169
|
+
return segments;
|
|
170
|
+
});
|
|
171
|
+
matcher = (path) => alternatives.some((segments) => matchSegments(segments, path));
|
|
172
|
+
}
|
|
173
|
+
cache.set(key, matcher);
|
|
174
|
+
return matcher;
|
|
175
|
+
}
|
|
176
|
+
/** 路径是否命中任意一个 glob。 */
|
|
177
|
+
export function matchesAny(path, patterns) {
|
|
178
|
+
return patterns.some((pattern) => createGlob(pattern)(path));
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* 取第一个命中 `match` 的 id:按定义顺序,无 `match` 的定义记住、留作兜底,
|
|
182
|
+
* 全都没有命中时用兜底,再没有就用 fallback。分组与分类的归属都走这一条规则。
|
|
183
|
+
*/
|
|
184
|
+
export function firstMatch(path, defs, fallback) {
|
|
185
|
+
let empty;
|
|
186
|
+
for (const def of defs) {
|
|
187
|
+
if (!def.match || def.match.length === 0) {
|
|
188
|
+
empty = def.id;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (matchesAny(path, def.match)) {
|
|
192
|
+
return def.id;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return empty ?? fallback;
|
|
196
|
+
}
|