@havocrao/picktui 0.1.0 → 0.1.1
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/README.md +17 -14
- package/dist/cjs/cand.js +52 -0
- package/dist/cjs/config.js +19 -0
- package/dist/cjs/confirm.js +93 -16
- package/dist/cjs/filter.js +294 -31
- package/dist/cjs/history.js +105 -11
- package/dist/cjs/index.js +3 -1
- package/dist/cjs/model.js +343 -0
- package/dist/cjs/toml.js +222 -0
- package/dist/cjs/tty.js +221 -0
- package/dist/cjs/tui.js +464 -46
- package/dist/esm/cand.js +47 -0
- package/dist/esm/config.js +16 -0
- package/dist/esm/confirm.js +87 -16
- package/dist/esm/filter.js +283 -31
- package/dist/esm/history.js +100 -11
- package/dist/esm/index.js +2 -1
- package/dist/esm/model.js +332 -0
- package/dist/esm/toml.js +216 -0
- package/dist/esm/tty.js +212 -0
- package/dist/esm/tui.js +461 -46
- package/dist/types/cand.d.ts +17 -0
- package/dist/types/config.d.ts +2 -0
- package/dist/types/confirm.d.ts +13 -4
- package/dist/types/filter.d.ts +41 -9
- package/dist/types/history.d.ts +13 -1
- package/dist/types/index.d.ts +2 -1
- package/dist/types/model.d.ts +48 -0
- package/dist/types/toml.d.ts +24 -0
- package/dist/types/tty.d.ts +42 -0
- package/dist/types/tui.d.ts +21 -9
- package/dist/types/types.d.ts +0 -7
- package/package.json +4 -4
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* model — 过滤选择器 TUI 状态机(纯逻辑,无 IO,可独立单测)。
|
|
3
|
+
*
|
|
4
|
+
* 对齐 Go 引擎 model:布局为 标题栏 / 分隔线 / 列表(视口滚动)/ 分隔线 /
|
|
5
|
+
* 输入行(闪烁光标)/ 状态栏;按键行为(循环移动、数字直选、多选勾选、
|
|
6
|
+
* ctrl+u/w 编辑)与引擎逐键一致。
|
|
7
|
+
*
|
|
8
|
+
* 本模块不接触终端:交互循环(tty.ts)只负责 按键 → update → view → 渲染。
|
|
9
|
+
*/
|
|
10
|
+
import { filterCands, highlightRanges } from './filter.js';
|
|
11
|
+
/** 气泡风格配色开关(NO_COLOR 环境变量时降级纯文本,保障脚本输出干净)。 */
|
|
12
|
+
let colorEnabled = true;
|
|
13
|
+
export function setColorEnabled(v) {
|
|
14
|
+
colorEnabled = v;
|
|
15
|
+
}
|
|
16
|
+
export function isColorEnabled() {
|
|
17
|
+
return colorEnabled;
|
|
18
|
+
}
|
|
19
|
+
/** 样式(对齐引擎 lipgloss 调色:120 亮绿 / 241 灰 / 36 青绿 / 213 粉 / 237 分隔)。 */
|
|
20
|
+
const STYLES = {
|
|
21
|
+
title: '\x1b[1;38;5;120m',
|
|
22
|
+
dim: '\x1b[38;5;241m',
|
|
23
|
+
ok: '\x1b[1;38;5;36m',
|
|
24
|
+
hl: '\x1b[38;5;213m',
|
|
25
|
+
cur: '\x1b[1;38;5;120m',
|
|
26
|
+
sep: '\x1b[38;5;237m',
|
|
27
|
+
prompt: '\x1b[1;38;5;120m',
|
|
28
|
+
reset: '\x1b[0m',
|
|
29
|
+
};
|
|
30
|
+
/** 包裹样式(颜色关闭时原样返回)。 */
|
|
31
|
+
function paint(name, s) {
|
|
32
|
+
if (!colorEnabled) {
|
|
33
|
+
return s;
|
|
34
|
+
}
|
|
35
|
+
return STYLES[name] + s + STYLES.reset;
|
|
36
|
+
}
|
|
37
|
+
/** 固定行数:标题(1) + 分隔线(1) + [列表] + 分隔线(1) + 输入(1) + 状态(1)。 */
|
|
38
|
+
const FIXED_LINES = 5;
|
|
39
|
+
export function newModel(opts) {
|
|
40
|
+
let initial = opts.initial;
|
|
41
|
+
if (initial < 0 || initial >= opts.cands.length) {
|
|
42
|
+
initial = 0;
|
|
43
|
+
}
|
|
44
|
+
const m = {
|
|
45
|
+
opts,
|
|
46
|
+
filtered: [],
|
|
47
|
+
query: opts.query,
|
|
48
|
+
cursor: initial,
|
|
49
|
+
scrollY: 0,
|
|
50
|
+
height: 0,
|
|
51
|
+
width: 0,
|
|
52
|
+
phase: 0,
|
|
53
|
+
cancelled: false,
|
|
54
|
+
quit: false,
|
|
55
|
+
selected: new Set(opts.selected),
|
|
56
|
+
};
|
|
57
|
+
recompute(m);
|
|
58
|
+
return m;
|
|
59
|
+
}
|
|
60
|
+
/** 多选模式:按 Cands 顺序返回勾选的 Value 列表。 */
|
|
61
|
+
export function selectedValues(m) {
|
|
62
|
+
if (m.selected.size === 0) {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
const out = [];
|
|
66
|
+
for (const c of m.opts.cands) {
|
|
67
|
+
if (m.selected.has(c.value)) {
|
|
68
|
+
out.push(c.value);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
/** 根据当前 query 重新过滤,保持光标在有效范围内。 */
|
|
74
|
+
function recompute(m) {
|
|
75
|
+
m.filtered = filterCands(m.opts.cands, m.query, m.opts.filterOpts);
|
|
76
|
+
if (m.cursor >= m.filtered.length) {
|
|
77
|
+
m.cursor = m.filtered.length - 1;
|
|
78
|
+
}
|
|
79
|
+
if (m.cursor < 0) {
|
|
80
|
+
m.cursor = 0;
|
|
81
|
+
}
|
|
82
|
+
ensureVisible(m);
|
|
83
|
+
}
|
|
84
|
+
/** 处理一个按键,返回新模型(不可变;变化时替换字段)。 */
|
|
85
|
+
export function update(m, key) {
|
|
86
|
+
switch (key) {
|
|
87
|
+
case 'ctrl+c':
|
|
88
|
+
case 'esc':
|
|
89
|
+
return { ...m, cancelled: true, quit: true };
|
|
90
|
+
case 'enter':
|
|
91
|
+
if (m.filtered.length > 0) {
|
|
92
|
+
return { ...m, quit: true };
|
|
93
|
+
}
|
|
94
|
+
return m;
|
|
95
|
+
case 'space':
|
|
96
|
+
// 多选模式:无过滤输入时空格勾选/取消;否则空格作为过滤字符
|
|
97
|
+
if (m.opts.multi && m.query === '' && m.cursor < m.filtered.length) {
|
|
98
|
+
const v = m.filtered[m.cursor].value;
|
|
99
|
+
const next = new Set(m.selected);
|
|
100
|
+
if (next.has(v)) {
|
|
101
|
+
next.delete(v);
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
next.add(v);
|
|
105
|
+
}
|
|
106
|
+
return { ...m, selected: next };
|
|
107
|
+
}
|
|
108
|
+
return appendQuery(m, ' ');
|
|
109
|
+
case 'up':
|
|
110
|
+
case 'k':
|
|
111
|
+
case 'ctrl+p':
|
|
112
|
+
return stepCursor(m, -1);
|
|
113
|
+
case 'down':
|
|
114
|
+
case 'j':
|
|
115
|
+
case 'ctrl+n':
|
|
116
|
+
case 'tab':
|
|
117
|
+
return stepCursor(m, 1);
|
|
118
|
+
case 'backspace':
|
|
119
|
+
if (m.query.length > 0) {
|
|
120
|
+
const runes = [...m.query];
|
|
121
|
+
runes.pop();
|
|
122
|
+
return recomputeQuery(m, runes.join(''));
|
|
123
|
+
}
|
|
124
|
+
return m;
|
|
125
|
+
case 'ctrl+u':
|
|
126
|
+
return recomputeQuery(m, '');
|
|
127
|
+
case 'ctrl+w':
|
|
128
|
+
return recomputeQuery(m, dropLastField(m.query));
|
|
129
|
+
default: {
|
|
130
|
+
// 统一为字符串处理(tty 键 'k'/'j' 等已在上方 case 匹配;数字是字符串)
|
|
131
|
+
const s = typeof key === 'object' ? key.value : key;
|
|
132
|
+
// 数字直选(菜单):无输入时 1-9 跳过逐行移动快速选中
|
|
133
|
+
if (m.opts.jumpKeys && m.query === '' && /^[1-9]$/.test(s)) {
|
|
134
|
+
const n = Number(s);
|
|
135
|
+
if (n - 1 < m.filtered.length) {
|
|
136
|
+
return { ...m, cursor: n - 1, quit: true };
|
|
137
|
+
}
|
|
138
|
+
return m;
|
|
139
|
+
}
|
|
140
|
+
if (s === '') {
|
|
141
|
+
return m; // 被忽略的键(如左右方向键)
|
|
142
|
+
}
|
|
143
|
+
return appendQuery(m, s);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function appendQuery(m, s) {
|
|
148
|
+
return recomputeQuery(m, m.query + s);
|
|
149
|
+
}
|
|
150
|
+
function recomputeQuery(m, q) {
|
|
151
|
+
const next = { ...m, query: q };
|
|
152
|
+
recompute(next);
|
|
153
|
+
return next;
|
|
154
|
+
}
|
|
155
|
+
/** 循环移动:首项按上键回到末项,末项按下键回到首项。 */
|
|
156
|
+
function stepCursor(m, delta) {
|
|
157
|
+
const n = m.filtered.length;
|
|
158
|
+
if (n <= 1) {
|
|
159
|
+
return m;
|
|
160
|
+
}
|
|
161
|
+
const cursor = (m.cursor + delta + n) % n;
|
|
162
|
+
const next = { ...m, cursor };
|
|
163
|
+
ensureVisible(next);
|
|
164
|
+
return next;
|
|
165
|
+
}
|
|
166
|
+
/** ctrl+w:去掉最后一词(与 Go strings.Fields 同语义)。 */
|
|
167
|
+
function dropLastField(q) {
|
|
168
|
+
const fields = q.trim().split(/\s+/).filter(Boolean);
|
|
169
|
+
if (fields.length === 0) {
|
|
170
|
+
return '';
|
|
171
|
+
}
|
|
172
|
+
return fields.slice(0, -1).join(' ');
|
|
173
|
+
}
|
|
174
|
+
/** 调整视口使光标可见(固定行数 5)。 */
|
|
175
|
+
function ensureVisible(m) {
|
|
176
|
+
const visibleRows = visibleRowsOf(m);
|
|
177
|
+
if (m.cursor < m.scrollY) {
|
|
178
|
+
m.scrollY = m.cursor;
|
|
179
|
+
}
|
|
180
|
+
if (m.cursor >= m.scrollY + visibleRows) {
|
|
181
|
+
m.scrollY = m.cursor - visibleRows + 1;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
function visibleRowsOf(m) {
|
|
185
|
+
const rows = m.height - FIXED_LINES;
|
|
186
|
+
return rows > 0 ? rows : 10; // 尺寸未到达时的默认值
|
|
187
|
+
}
|
|
188
|
+
/** 渲染整屏画面(带 ANSI 样式)。 */
|
|
189
|
+
export function view(m) {
|
|
190
|
+
const width = m.width > 0 ? m.width : 80;
|
|
191
|
+
const sep = paint('sep', '─'.repeat(width));
|
|
192
|
+
const out = [];
|
|
193
|
+
// 标题栏
|
|
194
|
+
let head = paint('title', m.opts.title);
|
|
195
|
+
if (m.opts.subtitle) {
|
|
196
|
+
head += paint('dim', ' · ' + m.opts.subtitle);
|
|
197
|
+
}
|
|
198
|
+
out.push(head, sep);
|
|
199
|
+
// 列表(视口区间)
|
|
200
|
+
const visibleRows = visibleRowsOf(m);
|
|
201
|
+
const end = Math.min(m.scrollY + visibleRows, m.filtered.length);
|
|
202
|
+
for (let i = m.scrollY; i < end; i++) {
|
|
203
|
+
const c = m.filtered[i];
|
|
204
|
+
let box = '';
|
|
205
|
+
if (m.opts.multi) {
|
|
206
|
+
box = m.selected.has(c.value) ? paint('ok', '[x] ') : '[ ] ';
|
|
207
|
+
}
|
|
208
|
+
if (i === m.cursor) {
|
|
209
|
+
// 光标行:整行绿色粗体(不再嵌套命中高亮,避免内层 reset 破坏外层颜色)
|
|
210
|
+
let line = ' ▸ ' + box + c.value;
|
|
211
|
+
if (c.desc) {
|
|
212
|
+
line += ' ' + c.desc;
|
|
213
|
+
}
|
|
214
|
+
const pad = width - displayWidth(line);
|
|
215
|
+
if (pad > 0) {
|
|
216
|
+
line += ' '.repeat(pad);
|
|
217
|
+
}
|
|
218
|
+
out.push(paint('cur', line));
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
out.push(' ' + box + renderCandidateLine(c, m.query, m.opts.filterOpts, width));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (m.filtered.length === 0) {
|
|
225
|
+
out.push(paint('dim', ' (no matches)'));
|
|
226
|
+
}
|
|
227
|
+
// 分隔线 + 输入行 + 状态栏
|
|
228
|
+
out.push(sep);
|
|
229
|
+
const cursorBlock = m.phase === 1 ? ' ' : '▏';
|
|
230
|
+
out.push(paint('prompt', '❯ ') + m.query + cursorBlock);
|
|
231
|
+
const count = `${m.filtered.length}/${m.opts.cands.length}`;
|
|
232
|
+
let status = ' ' + paint('ok', count);
|
|
233
|
+
if (m.opts.statusHint) {
|
|
234
|
+
status += paint('dim', ' · ' + m.opts.statusHint);
|
|
235
|
+
}
|
|
236
|
+
out.push(status);
|
|
237
|
+
return out.join('\n') + '\n';
|
|
238
|
+
}
|
|
239
|
+
/** 渲染候选行:value 命中高亮 + 灰色描述(按显示宽度截断)。 */
|
|
240
|
+
function renderCandidateLine(c, query, opts, width) {
|
|
241
|
+
const value = renderHighlighted(c.value, query, opts);
|
|
242
|
+
if (!c.desc) {
|
|
243
|
+
return value;
|
|
244
|
+
}
|
|
245
|
+
// 描述仅占 value 之后的剩余宽度(减 2 个分隔空格),太窄就不展示
|
|
246
|
+
const remaining = width - displayWidth(value) - 2;
|
|
247
|
+
if (remaining < 4) {
|
|
248
|
+
return value;
|
|
249
|
+
}
|
|
250
|
+
return value + ' ' + paint('dim', truncateWidth(c.desc, remaining));
|
|
251
|
+
}
|
|
252
|
+
/** 按显示宽度截断 s,超长末尾补 …。 */
|
|
253
|
+
export function truncateWidth(s, maxWidth) {
|
|
254
|
+
if (maxWidth <= 1) {
|
|
255
|
+
return '…';
|
|
256
|
+
}
|
|
257
|
+
if (displayWidth(s) <= maxWidth) {
|
|
258
|
+
return s;
|
|
259
|
+
}
|
|
260
|
+
let out = '';
|
|
261
|
+
let w = 0;
|
|
262
|
+
for (const r of s) {
|
|
263
|
+
const rw = charWidth(r);
|
|
264
|
+
if (w + rw > maxWidth - 1) {
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
out += r;
|
|
268
|
+
w += rw;
|
|
269
|
+
}
|
|
270
|
+
return out + '…';
|
|
271
|
+
}
|
|
272
|
+
/** 渲染候选字符串,高亮匹配片段(粉色)。 */
|
|
273
|
+
function renderHighlighted(s, query, opts) {
|
|
274
|
+
const ranges = highlightRanges(s, query, opts);
|
|
275
|
+
if (ranges.length === 0) {
|
|
276
|
+
return s;
|
|
277
|
+
}
|
|
278
|
+
const runes = [...s];
|
|
279
|
+
let out = '';
|
|
280
|
+
let pos = 0;
|
|
281
|
+
for (const [start, end] of ranges) {
|
|
282
|
+
if (pos < start) {
|
|
283
|
+
out += runes.slice(pos, start).join('');
|
|
284
|
+
}
|
|
285
|
+
out += paint('hl', runes.slice(start, end).join(''));
|
|
286
|
+
pos = end;
|
|
287
|
+
}
|
|
288
|
+
if (pos < runes.length) {
|
|
289
|
+
out += runes.slice(pos).join('');
|
|
290
|
+
}
|
|
291
|
+
return out;
|
|
292
|
+
}
|
|
293
|
+
/** 字符显示宽度(简化 wcwidth:CJK/全角=2,控制符=0,其余=1)。 */
|
|
294
|
+
export function charWidth(ch) {
|
|
295
|
+
const cp = ch.codePointAt(0) ?? 0;
|
|
296
|
+
if (cp < 0x20 || cp === 0x7f) {
|
|
297
|
+
return 0;
|
|
298
|
+
}
|
|
299
|
+
if (isWide(cp)) {
|
|
300
|
+
return 2;
|
|
301
|
+
}
|
|
302
|
+
return 1;
|
|
303
|
+
}
|
|
304
|
+
/** 字符串显示宽度。 */
|
|
305
|
+
export function displayWidth(s) {
|
|
306
|
+
let w = 0;
|
|
307
|
+
for (const ch of s) {
|
|
308
|
+
w += charWidth(ch);
|
|
309
|
+
}
|
|
310
|
+
return w;
|
|
311
|
+
}
|
|
312
|
+
/** East Asian Wide / Fullwidth / emoji 等宽字符区间判定。 */
|
|
313
|
+
function isWide(cp) {
|
|
314
|
+
return ((cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo
|
|
315
|
+
(cp >= 0x2e80 && cp <= 0x303e) || // CJK Radicals 等
|
|
316
|
+
(cp >= 0x3041 && cp <= 0x33ff) || // 假名 / CJK 符号
|
|
317
|
+
(cp >= 0x3400 && cp <= 0x4dbf) || // CJK 扩展 A
|
|
318
|
+
(cp >= 0x4e00 && cp <= 0x9fff) || // CJK 统一表意
|
|
319
|
+
(cp >= 0xa000 && cp <= 0xa4cf) || // 彝文
|
|
320
|
+
(cp >= 0xa960 && cp <= 0xa97f) || // Hangul Jamo Extended-A
|
|
321
|
+
(cp >= 0xac00 && cp <= 0xd7a3) || // Hangul 音节
|
|
322
|
+
(cp >= 0xf900 && cp <= 0xfaff) || // CJK 兼容表意
|
|
323
|
+
(cp >= 0xfe10 && cp <= 0xfe19) || // 竖排形式
|
|
324
|
+
(cp >= 0xfe30 && cp <= 0xfe52) ||
|
|
325
|
+
(cp >= 0xfe54 && cp <= 0xfe66) ||
|
|
326
|
+
(cp >= 0xfe68 && cp <= 0xfe6b) ||
|
|
327
|
+
(cp >= 0xff00 && cp <= 0xff60) || // 全角形式
|
|
328
|
+
(cp >= 0xffe0 && cp <= 0xffe6) ||
|
|
329
|
+
(cp >= 0x1f1e6 && cp <= 0x1f1ff) || // 区域指示符(旗帜)
|
|
330
|
+
(cp >= 0x1f300 && cp <= 0x1faff) // emoji
|
|
331
|
+
);
|
|
332
|
+
}
|
package/dist/esm/toml.js
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/** 裸 key 允许的字符(对齐 TOML bare key;其余 key 编码时加引号)。 */
|
|
2
|
+
const BARE_KEY_RE = /^[A-Za-z0-9_-]+$/;
|
|
3
|
+
/** 转义字符串内容(写入值时用)。 */
|
|
4
|
+
export function escapeTomlString(s) {
|
|
5
|
+
let out = '';
|
|
6
|
+
for (const ch of s) {
|
|
7
|
+
switch (ch) {
|
|
8
|
+
case '\\':
|
|
9
|
+
out += '\\\\';
|
|
10
|
+
break;
|
|
11
|
+
case '"':
|
|
12
|
+
out += '\\"';
|
|
13
|
+
break;
|
|
14
|
+
case '\n':
|
|
15
|
+
out += '\\n';
|
|
16
|
+
break;
|
|
17
|
+
case '\t':
|
|
18
|
+
out += '\\t';
|
|
19
|
+
break;
|
|
20
|
+
case '\r':
|
|
21
|
+
out += '\\r';
|
|
22
|
+
break;
|
|
23
|
+
case '\b':
|
|
24
|
+
out += '\\b';
|
|
25
|
+
break;
|
|
26
|
+
case '\f':
|
|
27
|
+
out += '\\f';
|
|
28
|
+
break;
|
|
29
|
+
default:
|
|
30
|
+
out += ch;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
/** 带引号的字符串字面量(含转义)。 */
|
|
36
|
+
export function quoteTomlString(s) {
|
|
37
|
+
return '"' + escapeTomlString(s) + '"';
|
|
38
|
+
}
|
|
39
|
+
/** 表头/键名编码:裸 key 直接输出,否则带引号。 */
|
|
40
|
+
export function keyLiteral(key) {
|
|
41
|
+
return BARE_KEY_RE.test(key) ? key : quoteTomlString(key);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* 解析 TOML 子集。无法解析的行跳过(容错:状态文件损坏不阻断主流程)。
|
|
45
|
+
* 无任何可解析内容时返回 null。
|
|
46
|
+
*/
|
|
47
|
+
export function parseToml(text) {
|
|
48
|
+
const doc = new Map();
|
|
49
|
+
let cur = null;
|
|
50
|
+
let sawContent = false;
|
|
51
|
+
for (const rawLine of text.split('\n')) {
|
|
52
|
+
const line = rawLine.trim();
|
|
53
|
+
if (line === '' || line.startsWith('#')) {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (line.startsWith('[') && line.endsWith(']')) {
|
|
57
|
+
const name = parseQuotedOrBare(line.slice(1, -1).trim());
|
|
58
|
+
if (name === null) {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
cur = new Map();
|
|
62
|
+
doc.set(name, cur);
|
|
63
|
+
sawContent = true;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (cur === null) {
|
|
67
|
+
continue; // 表头之前的游离键:本包格式不存在,跳过
|
|
68
|
+
}
|
|
69
|
+
const eq = line.indexOf('=');
|
|
70
|
+
if (eq < 0) {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const keyRaw = line.slice(0, eq).trim();
|
|
74
|
+
const key = parseQuotedOrBare(keyRaw);
|
|
75
|
+
if (key === null) {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const valueRaw = line.slice(eq + 1).trim();
|
|
79
|
+
const value = parseTomlValue(valueRaw);
|
|
80
|
+
if (value === null) {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
cur.set(key, value);
|
|
84
|
+
sawContent = true;
|
|
85
|
+
}
|
|
86
|
+
if (!sawContent) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
return doc;
|
|
90
|
+
}
|
|
91
|
+
/** 解析 `"quoted"` 或裸字符串;失败返回 null。 */
|
|
92
|
+
function parseQuotedOrBare(s) {
|
|
93
|
+
if (s.startsWith('"')) {
|
|
94
|
+
return parseTomlString(s);
|
|
95
|
+
}
|
|
96
|
+
return s;
|
|
97
|
+
}
|
|
98
|
+
/** 解析一个 TOML 字符串字面量(从首个引号到闭合引号,含转义)。 */
|
|
99
|
+
function parseTomlString(s) {
|
|
100
|
+
if (!s.startsWith('"')) {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
let out = '';
|
|
104
|
+
let i = 1;
|
|
105
|
+
while (i < s.length) {
|
|
106
|
+
const ch = s[i];
|
|
107
|
+
if (ch === '"') {
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
if (ch === '\\') {
|
|
111
|
+
const esc = s[i + 1];
|
|
112
|
+
if (esc === undefined) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
switch (esc) {
|
|
116
|
+
case 'n':
|
|
117
|
+
out += '\n';
|
|
118
|
+
break;
|
|
119
|
+
case 't':
|
|
120
|
+
out += '\t';
|
|
121
|
+
break;
|
|
122
|
+
case 'r':
|
|
123
|
+
out += '\r';
|
|
124
|
+
break;
|
|
125
|
+
case 'b':
|
|
126
|
+
out += '\b';
|
|
127
|
+
break;
|
|
128
|
+
case 'f':
|
|
129
|
+
out += '\f';
|
|
130
|
+
break;
|
|
131
|
+
case '"':
|
|
132
|
+
out += '"';
|
|
133
|
+
break;
|
|
134
|
+
case '\\':
|
|
135
|
+
out += '\\';
|
|
136
|
+
break;
|
|
137
|
+
case 'u': {
|
|
138
|
+
const hex = s.slice(i + 2, i + 6);
|
|
139
|
+
if (!/^[0-9a-fA-F]{4}$/.test(hex)) {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
out += String.fromCodePoint(parseInt(hex, 16));
|
|
143
|
+
i += 4;
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
default:
|
|
147
|
+
return null; // 不识别的转义
|
|
148
|
+
}
|
|
149
|
+
i += 2;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
// TOML 基本字符串不允许裸控制字符
|
|
153
|
+
if (ch < ' ' && ch !== '\t') {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
out += ch;
|
|
157
|
+
i++;
|
|
158
|
+
}
|
|
159
|
+
return null; // 未闭合
|
|
160
|
+
}
|
|
161
|
+
/** 解析字符串或字符串数组字面量;失败返回 null。 */
|
|
162
|
+
function parseTomlValue(raw) {
|
|
163
|
+
if (raw.startsWith('[')) {
|
|
164
|
+
if (!raw.endsWith(']')) {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
const inner = raw.slice(1, -1).trim();
|
|
168
|
+
if (inner === '') {
|
|
169
|
+
return [];
|
|
170
|
+
}
|
|
171
|
+
const values = [];
|
|
172
|
+
let i = 0;
|
|
173
|
+
while (i < inner.length) {
|
|
174
|
+
const ch = inner[i];
|
|
175
|
+
if (ch === ',' || ch === ' ' || ch === '\t') {
|
|
176
|
+
i++;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (ch === '"') {
|
|
180
|
+
const start = i;
|
|
181
|
+
let j = i + 1;
|
|
182
|
+
let escaped = false;
|
|
183
|
+
while (j < inner.length) {
|
|
184
|
+
const c = inner[j];
|
|
185
|
+
if (c === '"' && !escaped) {
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
if (c === '\\' && !escaped) {
|
|
189
|
+
escaped = true;
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
escaped = false;
|
|
193
|
+
}
|
|
194
|
+
j++;
|
|
195
|
+
}
|
|
196
|
+
if (j >= inner.length) {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
const literal = parseTomlString(inner.slice(start, j + 1));
|
|
200
|
+
if (literal === null) {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
values.push(literal);
|
|
204
|
+
i = j + 1;
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
return null; // 仅支持字符串数组
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return values;
|
|
211
|
+
}
|
|
212
|
+
if (raw.startsWith('"')) {
|
|
213
|
+
return parseTomlString(raw);
|
|
214
|
+
}
|
|
215
|
+
return null; // 布尔/数字等非本包类型
|
|
216
|
+
}
|