@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
package/README.md
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
# @havocrao/picktui —
|
|
1
|
+
# @havocrao/picktui — TUI 候选选择器(纯 TypeScript)
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
纯 TS 实现:过滤/匹配/交互选择/记忆全部在 JS 内完成,**零运行时依赖、
|
|
4
|
+
无需任何引擎二进制**,安装即用。行为与 Go 引擎(`../go/cmd/picktui`)语义
|
|
5
|
+
逐项对齐(匹配算法、高亮区间、非交互退化、状态文件格式互兼容)。
|
|
5
6
|
同份源码构建 esm + cjs + d.ts,TS 与 JS 共用。
|
|
6
7
|
|
|
7
8
|
## 安装
|
|
@@ -10,10 +11,10 @@
|
|
|
10
11
|
$ npm install @havocrao/picktui
|
|
11
12
|
```
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
`/dev/tty
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
交互 TUI 由本包渲染:POSIX 上打开 `/dev/tty`(宿主 stdout 干净,`$(...)`
|
|
15
|
+
安全);无 `/dev/tty` 的环境(如 Windows)退化为宿主 stdio。无 TTY 时
|
|
16
|
+
自动退化(无 query 取首个、有 query 过滤取首);取消(esc/ctrl+c)
|
|
17
|
+
返回 `null`;错误抛 `PicktuiError`(含 exitCode/stderr 诊断)。
|
|
17
18
|
|
|
18
19
|
## API
|
|
19
20
|
|
|
@@ -23,7 +24,7 @@ import { pick, menu, rawPick } from '@havocrao/picktui/tui'
|
|
|
23
24
|
import { histGet, histSet } from '@havocrao/picktui/history'
|
|
24
25
|
import { confirmCheck, confirmAdd } from '@havocrao/picktui/confirm'
|
|
25
26
|
import type { Candidate, FilterOptions, FilteredCandidate, PickFlags } from '@havocrao/picktui/types'
|
|
26
|
-
import {
|
|
27
|
+
import { VERSION } from '@havocrao/picktui'
|
|
27
28
|
```
|
|
28
29
|
|
|
29
30
|
| 函数 | 说明 |
|
|
@@ -32,19 +33,21 @@ import { engineVersion, assertEngineVersion } from '@havocrao/picktui'
|
|
|
32
33
|
| `resolve(cands, query)` | 精确/唯一前缀解析;无唯一解返回 `null` |
|
|
33
34
|
| `pick(cands?, flags?)` | 交互过滤选择;`flags` 透传 `-q/--label/--sep/--fuzzy/-1/--auto` |
|
|
34
35
|
| `menu(label, cands)` | 多值缩写菜单(数字 1-9 直选) |
|
|
35
|
-
| `rawPick([...args])` |
|
|
36
|
+
| `rawPick([...args])` | 透传 pick 参数(`--from`/`--map` 等) |
|
|
36
37
|
| `histGet(label)` / `histSet(label, value)` | 选择记忆 |
|
|
37
38
|
| `confirmCheck` / `confirmAdd` | 自动匹配首次确认(add 幂等) |
|
|
38
|
-
| `
|
|
39
|
+
| `VERSION` | 本实现语义版本 |
|
|
40
|
+
|
|
41
|
+
注:`pick` 候选来自参数(不读宿主进程自身 stdin);需要命令/管道来源时用
|
|
42
|
+
`rawPick(['--from', '<cmd>', ...])`(10s 超时,失败带 stderr 诊断)。
|
|
39
43
|
|
|
40
44
|
## 开发
|
|
41
45
|
|
|
42
46
|
```console
|
|
43
|
-
$ npm install # 安装 devDependencies(typescript
|
|
47
|
+
$ npm install # 安装 devDependencies(typescript,无运行时依赖)
|
|
44
48
|
$ npm run build # tsc 双输出:dist/esm + dist/cjs + dist/types
|
|
45
|
-
$ npm test #
|
|
49
|
+
$ npm test # node:test 全量测试(filter/history/confirm/tui/model)
|
|
46
50
|
$ npm run test:pack # npm pack → 干净目录安装 → import/require 冒烟
|
|
47
51
|
```
|
|
48
52
|
|
|
49
|
-
|
|
50
|
-
由引擎侧 model 级测试覆盖。
|
|
53
|
+
测试均为纯 JS 断言,无需二进制或 TTY:交互 TUI 的按键行为由 model 级测试覆盖。
|
package/dist/cjs/cand.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.serializeLines = serializeLines;
|
|
4
|
+
exports.structuredCandidates = structuredCandidates;
|
|
5
|
+
exports.toStructuredCands = toStructuredCands;
|
|
6
|
+
/** 将 `string | Candidate` 输入序列化为协议结构化行(key<TAB>description)。 */
|
|
7
|
+
function serializeLines(cands) {
|
|
8
|
+
return cands.map((c) => (typeof c === 'string' ? c : c.desc ? `${c.value}\t${c.desc}` : c.value));
|
|
9
|
+
}
|
|
10
|
+
/** 将原始行列表解析为结构化候选(与 Go StructuredCandidates 同语义)。 */
|
|
11
|
+
function structuredCandidates(lines) {
|
|
12
|
+
const idx = new Map();
|
|
13
|
+
const out = [];
|
|
14
|
+
for (const line of lines) {
|
|
15
|
+
let value = line;
|
|
16
|
+
let desc = '';
|
|
17
|
+
const tab = line.indexOf('\t');
|
|
18
|
+
if (tab >= 0) {
|
|
19
|
+
value = line.slice(0, tab);
|
|
20
|
+
desc = line.slice(tab + 1);
|
|
21
|
+
}
|
|
22
|
+
value = value.trim();
|
|
23
|
+
if (value === '') {
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (value.startsWith('* ')) {
|
|
27
|
+
value = value.slice(2);
|
|
28
|
+
}
|
|
29
|
+
else if (value.startsWith('+ ')) {
|
|
30
|
+
value = value.slice(2);
|
|
31
|
+
}
|
|
32
|
+
value = value.trim();
|
|
33
|
+
if (value === '') {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
desc = desc.trim();
|
|
37
|
+
const j = idx.get(value);
|
|
38
|
+
if (j !== undefined) {
|
|
39
|
+
if (out[j].desc === '' && desc !== '') {
|
|
40
|
+
out[j] = { value, desc };
|
|
41
|
+
}
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
idx.set(value, out.length);
|
|
45
|
+
out.push({ value, desc });
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
/** 输入候选统一解析为结构化候选(filter/resolve/pick 共用入口)。 */
|
|
50
|
+
function toStructuredCands(cands) {
|
|
51
|
+
return structuredCandidates(serializeLines(cands));
|
|
52
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.dataDir = dataDir;
|
|
4
|
+
/**
|
|
5
|
+
* config — 数据目录解析(对齐协议:hist/confirm/pick --label 状态文件所在)。
|
|
6
|
+
*
|
|
7
|
+
* 优先级:$PICKTUI_CONFIG_DIR → $XDG_CONFIG_HOME/picktui → ~/.config/picktui。
|
|
8
|
+
*/
|
|
9
|
+
const node_os_1 = require("node:os");
|
|
10
|
+
const node_path_1 = require("node:path");
|
|
11
|
+
/** 状态文件目录(history.toml / confirm.toml 所在)。 */
|
|
12
|
+
function dataDir() {
|
|
13
|
+
const explicit = process.env.PICKTUI_CONFIG_DIR;
|
|
14
|
+
if (explicit) {
|
|
15
|
+
return explicit;
|
|
16
|
+
}
|
|
17
|
+
const base = process.env.XDG_CONFIG_HOME || (0, node_path_1.join)((0, node_os_1.homedir)(), '.config');
|
|
18
|
+
return (0, node_path_1.join)(base, 'picktui');
|
|
19
|
+
}
|
package/dist/cjs/confirm.js
CHANGED
|
@@ -1,28 +1,105 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CONFIRM_FILE = void 0;
|
|
4
|
+
exports.confirmPath = confirmPath;
|
|
5
|
+
exports.loadConfirmed = loadConfirmed;
|
|
6
|
+
exports.isConfirmed = isConfirmed;
|
|
7
|
+
exports.confirm = confirm;
|
|
8
|
+
exports.parseConfirmAnswer = parseConfirmAnswer;
|
|
3
9
|
exports.confirmCheck = confirmCheck;
|
|
4
10
|
exports.confirmAdd = confirmAdd;
|
|
5
11
|
/**
|
|
6
|
-
* confirm —
|
|
12
|
+
* confirm — 自动匹配的首次用户确认记录(confirm.toml)。
|
|
7
13
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
14
|
+
* 文件格式与 Go 引擎 BurntSushi/toml 输出完全兼容:
|
|
15
|
+
*
|
|
16
|
+
* # picktui confirm — user-confirmed auto resolutions (auto-managed)
|
|
17
|
+
* [confirmed]
|
|
18
|
+
* "npm run" = ["release", "dev"]
|
|
19
|
+
*
|
|
20
|
+
* label 为空时不做任何落盘(返回 false)。文件不存在或损坏 → 空记录
|
|
21
|
+
* (确认记录只是安全辅助,缺失最多导致首次匹配再次询问)。
|
|
10
22
|
*/
|
|
11
|
-
const
|
|
23
|
+
const node_fs_1 = require("node:fs");
|
|
24
|
+
const node_path_1 = require("node:path");
|
|
25
|
+
const config_js_1 = require("./config.js");
|
|
26
|
+
const toml_js_1 = require("./toml.js");
|
|
27
|
+
const types_js_1 = require("./types.js");
|
|
28
|
+
/** 自动匹配确认记录文件名。 */
|
|
29
|
+
exports.CONFIRM_FILE = 'confirm.toml';
|
|
30
|
+
/** 注释头(对齐引擎输出)。 */
|
|
31
|
+
const HEADER = '# picktui confirm — user-confirmed auto resolutions (auto-managed)';
|
|
32
|
+
/** 返回确认记录文件路径。 */
|
|
33
|
+
function confirmPath() {
|
|
34
|
+
return (0, node_path_1.join)((0, config_js_1.dataDir)(), exports.CONFIRM_FILE);
|
|
35
|
+
}
|
|
36
|
+
/** 解码 confirm.toml 为 label → 已确认值列表;缺失/损坏时返回空 Map。 */
|
|
37
|
+
function loadConfirmed() {
|
|
38
|
+
const p = confirmPath();
|
|
39
|
+
if (!(0, node_fs_1.existsSync)(p)) {
|
|
40
|
+
return new Map();
|
|
41
|
+
}
|
|
42
|
+
const doc = (0, toml_js_1.parseToml)((0, node_fs_1.readFileSync)(p, 'utf8'));
|
|
43
|
+
if (doc === null) {
|
|
44
|
+
return new Map();
|
|
45
|
+
}
|
|
46
|
+
const confirmed = doc.get('confirmed');
|
|
47
|
+
if (!confirmed) {
|
|
48
|
+
return new Map();
|
|
49
|
+
}
|
|
50
|
+
const out = new Map();
|
|
51
|
+
for (const [label, values] of confirmed) {
|
|
52
|
+
if (Array.isArray(values)) {
|
|
53
|
+
out.set(label, values.map(String));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
/** 报告 (label, value) 是否已被确认过;label 为空时恒为 false。 */
|
|
59
|
+
function isConfirmed(label, value) {
|
|
60
|
+
if (label === '') {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
return (loadConfirmed().get(label) ?? []).includes(value);
|
|
64
|
+
}
|
|
65
|
+
/** 记录 (label, value) 为已确认(幂等;label 为空不落盘)。 */
|
|
66
|
+
function confirm(label, value) {
|
|
67
|
+
if (label === '') {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const m = loadConfirmed();
|
|
71
|
+
const list = m.get(label) ?? [];
|
|
72
|
+
if (list.includes(value)) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
list.push(value);
|
|
76
|
+
m.set(label, list);
|
|
77
|
+
const lines = [HEADER, '[confirmed]'];
|
|
78
|
+
const labels = [...m.keys()].sort();
|
|
79
|
+
for (const l of labels) {
|
|
80
|
+
const values = (m.get(l) ?? []).map(toml_js_1.escapeTomlString).map((v) => `"${v}"`);
|
|
81
|
+
lines.push(`${(0, toml_js_1.keyLiteral)(l)} = [${values.join(', ')}]`);
|
|
82
|
+
}
|
|
83
|
+
const p = confirmPath();
|
|
84
|
+
try {
|
|
85
|
+
(0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(p), { recursive: true });
|
|
86
|
+
(0, node_fs_1.writeFileSync)(p, lines.join('\n') + '\n', { mode: 0o644 });
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
throw new types_js_1.PicktuiError(`写入确认记录失败:${err instanceof Error ? err.message : String(err)}`, 1, '');
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/** 解析确认回答:y/yes(大小写不敏感,容忍首尾空白)为确认。 */
|
|
93
|
+
function parseConfirmAnswer(s) {
|
|
94
|
+
const t = s.trim().toLowerCase();
|
|
95
|
+
return t === 'y' || t === 'yes';
|
|
96
|
+
}
|
|
12
97
|
/** 报告 (label, value) 是否已被确认过。 */
|
|
13
98
|
async function confirmCheck(label, value) {
|
|
14
|
-
|
|
15
|
-
(0, engine_js_1.assertSuccess)(inv, 'picktui confirm check');
|
|
16
|
-
const parsed = (0, engine_js_1.parseJSON)(inv.stdout, 'picktui confirm check');
|
|
17
|
-
return parsed.confirmed;
|
|
99
|
+
return isConfirmed(label, value);
|
|
18
100
|
}
|
|
19
|
-
/**
|
|
20
|
-
* 记录 (label, value) 为已确认(幂等)。
|
|
21
|
-
* @returns 确认后的实际状态(label 为空时不落盘 → false)
|
|
22
|
-
*/
|
|
101
|
+
/** 记录 (label, value) 为已确认(幂等);返回确认后的实际状态。 */
|
|
23
102
|
async function confirmAdd(label, value) {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const parsed = (0, engine_js_1.parseJSON)(inv.stdout, 'picktui confirm add');
|
|
27
|
-
return parsed.confirmed;
|
|
103
|
+
confirm(label, value);
|
|
104
|
+
return isConfirmed(label, value);
|
|
28
105
|
}
|
package/dist/cjs/filter.js
CHANGED
|
@@ -1,49 +1,312 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.splitKeywords = splitKeywords;
|
|
4
|
+
exports.matchSubstringAll = matchSubstringAll;
|
|
5
|
+
exports.matchTokenPrefixAll = matchTokenPrefixAll;
|
|
6
|
+
exports.matchFuzzyAll = matchFuzzyAll;
|
|
7
|
+
exports.isSubsequence = isSubsequence;
|
|
8
|
+
exports.mergeRanges = mergeRanges;
|
|
9
|
+
exports.highlightRanges = highlightRanges;
|
|
10
|
+
exports.matchCandidate = matchCandidate;
|
|
11
|
+
exports.normalizeOpts = normalizeOpts;
|
|
12
|
+
exports.filterCands = filterCands;
|
|
13
|
+
exports.autoResolve = autoResolve;
|
|
3
14
|
exports.filter = filter;
|
|
4
15
|
exports.resolve = resolve;
|
|
5
16
|
/**
|
|
6
|
-
* filter / resolve —
|
|
17
|
+
* filter / resolve — 纯 TS 过滤匹配引擎(对齐 Go filter.go,零依赖)。
|
|
7
18
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
19
|
+
* 三种匹配模式(与协议一致):
|
|
20
|
+
* - 子串 AND(默认):空格分关键字,全部命中才保留,大小写不敏感
|
|
21
|
+
* - token 前缀(--sep <chars>):按分隔符集合切 token(默认 _,可多字符如 ":_"),
|
|
22
|
+
* 每关键字匹配某 token 前缀
|
|
23
|
+
* - 子序列模糊(--fuzzy):关键字为子序列
|
|
24
|
+
*
|
|
25
|
+
* 高亮区间为 rune(code point)索引 [start, end),排序合并;空查询恒 []。
|
|
10
26
|
*/
|
|
11
|
-
const
|
|
12
|
-
/**
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
27
|
+
const cand_js_1 = require("./cand.js");
|
|
28
|
+
/** 默认分隔符集合(token 模式)。 */
|
|
29
|
+
const DEFAULT_SEP = '_';
|
|
30
|
+
/** 将查询字符串拆分为小写关键字列表(空格分词、小写化、过滤空串)。 */
|
|
31
|
+
function splitKeywords(query) {
|
|
32
|
+
const fields = query.trim().split(/\s+/).filter(Boolean);
|
|
33
|
+
return fields.map((f) => f.toLowerCase());
|
|
34
|
+
}
|
|
35
|
+
/** 按 rune 切分字符串为 code point 数组(与 Go []rune 对齐)。 */
|
|
36
|
+
function toRunes(s) {
|
|
37
|
+
return [...s];
|
|
38
|
+
}
|
|
39
|
+
/** 某 rune 是否在分隔符集合中。 */
|
|
40
|
+
function sepSetOf(sep) {
|
|
41
|
+
return new Set(toRunes(sep));
|
|
42
|
+
}
|
|
43
|
+
/** 按分隔符集合切分 runes,返回每个非空 token 的 [start, end) rune 索引区间。 */
|
|
44
|
+
function splitRuneRanges(runes, seps) {
|
|
45
|
+
if (seps.size === 0) {
|
|
46
|
+
return runes.length > 0 ? [[0, runes.length]] : [];
|
|
47
|
+
}
|
|
48
|
+
const ranges = [];
|
|
49
|
+
let start = 0;
|
|
50
|
+
for (let i = 0; i < runes.length; i++) {
|
|
51
|
+
if (seps.has(runes[i])) {
|
|
52
|
+
if (i > start) {
|
|
53
|
+
ranges.push([start, i]);
|
|
54
|
+
}
|
|
55
|
+
start = i + 1;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (start < runes.length) {
|
|
59
|
+
ranges.push([start, runes.length]);
|
|
60
|
+
}
|
|
61
|
+
return ranges;
|
|
62
|
+
}
|
|
63
|
+
/** 按分隔符集合切分字符串为 token 列表(供匹配用)。 */
|
|
64
|
+
function splitTokens(s, seps) {
|
|
65
|
+
if (seps.size === 0) {
|
|
66
|
+
return [s];
|
|
67
|
+
}
|
|
68
|
+
const tokens = [];
|
|
69
|
+
let cur = '';
|
|
70
|
+
for (const r of s) {
|
|
71
|
+
if (seps.has(r)) {
|
|
72
|
+
if (cur !== '') {
|
|
73
|
+
tokens.push(cur);
|
|
74
|
+
cur = '';
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
cur += r;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (cur !== '') {
|
|
82
|
+
tokens.push(cur);
|
|
83
|
+
}
|
|
84
|
+
return tokens;
|
|
85
|
+
}
|
|
86
|
+
/** 子串 AND:所有关键字都需作为子串出现(大小写不敏感)。 */
|
|
87
|
+
function matchSubstringAll(s, kws) {
|
|
88
|
+
const lower = s.toLowerCase();
|
|
89
|
+
return kws.every((kw) => lower.includes(kw));
|
|
90
|
+
}
|
|
91
|
+
/** token 前缀:按分隔符集合切 token,每关键字须匹配某 token 的前缀。 */
|
|
92
|
+
function matchTokenPrefixAll(s, kws, sep) {
|
|
93
|
+
const seps = sepSetOf(sep === '' ? DEFAULT_SEP : sep);
|
|
94
|
+
const tokens = splitTokens(s.toLowerCase(), seps);
|
|
95
|
+
return kws.every((kw) => tokens.some((tok) => tok.startsWith(kw)));
|
|
96
|
+
}
|
|
97
|
+
/** 子序列模糊:所有关键字都需作为子序列出现(大小写不敏感)。 */
|
|
98
|
+
function matchFuzzyAll(s, kws) {
|
|
99
|
+
const lower = s.toLowerCase();
|
|
100
|
+
return kws.every((kw) => isSubsequence(kw, lower));
|
|
101
|
+
}
|
|
102
|
+
/** pat 是否为 s 的子序列(不要求连续)。 */
|
|
103
|
+
function isSubsequence(pat, s) {
|
|
104
|
+
if (pat === '') {
|
|
105
|
+
return true;
|
|
17
106
|
}
|
|
18
|
-
|
|
19
|
-
|
|
107
|
+
let pi = 0;
|
|
108
|
+
for (let si = 0; si < s.length && pi < pat.length; si++) {
|
|
109
|
+
if (s[si] === pat[pi]) {
|
|
110
|
+
pi++;
|
|
111
|
+
}
|
|
20
112
|
}
|
|
21
|
-
return
|
|
113
|
+
return pi === pat.length;
|
|
114
|
+
}
|
|
115
|
+
/** rune 索引:在 s 中查找 substr 的首个出现位置,未找到返回 -1。 */
|
|
116
|
+
function runeIndex(s, substr) {
|
|
117
|
+
if (substr.length === 0 || substr.length > s.length) {
|
|
118
|
+
return -1;
|
|
119
|
+
}
|
|
120
|
+
for (let i = 0; i <= s.length - substr.length; i++) {
|
|
121
|
+
let match = true;
|
|
122
|
+
for (let j = 0; j < substr.length; j++) {
|
|
123
|
+
if (s[i + j] !== substr[j]) {
|
|
124
|
+
match = false;
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (match) {
|
|
129
|
+
return i;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return -1;
|
|
133
|
+
}
|
|
134
|
+
/** 排序并合并重叠的 [start, end) 区间(与 Go mergeRanges 一致)。 */
|
|
135
|
+
function mergeRanges(ranges) {
|
|
136
|
+
if (ranges.length === 0) {
|
|
137
|
+
return [];
|
|
138
|
+
}
|
|
139
|
+
const sorted = [...ranges].sort((a, b) => (a[0] !== b[0] ? a[0] - b[0] : a[1] - b[1]));
|
|
140
|
+
const merged = [sorted[0]];
|
|
141
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
142
|
+
const last = merged[merged.length - 1];
|
|
143
|
+
const r = sorted[i];
|
|
144
|
+
if (r[0] <= last[1]) {
|
|
145
|
+
if (r[1] > last[1]) {
|
|
146
|
+
last[1] = r[1];
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
merged.push(r);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return merged;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* highlightRanges 返回 s 中匹配关键字的 [start, end) rune 索引区间(已排序合并)。
|
|
157
|
+
* 空查询返回 []。
|
|
158
|
+
*/
|
|
159
|
+
function highlightRanges(s, query, opts = {}) {
|
|
160
|
+
const kws = splitKeywords(query);
|
|
161
|
+
if (kws.length === 0) {
|
|
162
|
+
return [];
|
|
163
|
+
}
|
|
164
|
+
const lowerRunes = toRunes(s.toLowerCase());
|
|
165
|
+
const ranges = [];
|
|
166
|
+
const mode = opts.mode ?? 'substring';
|
|
167
|
+
if (mode === 'token') {
|
|
168
|
+
const seps = sepSetOf(opts.sep === undefined || opts.sep === '' ? DEFAULT_SEP : opts.sep);
|
|
169
|
+
const tokRanges = splitRuneRanges(lowerRunes, seps);
|
|
170
|
+
for (const kw of kws) {
|
|
171
|
+
const kwRunes = toRunes(kw);
|
|
172
|
+
if (kwRunes.length === 0) {
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
for (const tr of tokRanges) {
|
|
176
|
+
const tok = lowerRunes.slice(tr[0], tr[1]);
|
|
177
|
+
if (hasRunePrefix(tok, kwRunes)) {
|
|
178
|
+
ranges.push([tr[0], tr[0] + kwRunes.length]);
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
else if (mode === 'fuzzy') {
|
|
185
|
+
for (const kw of kws) {
|
|
186
|
+
const positions = subsequenceRunePositions(toRunes(kw), lowerRunes);
|
|
187
|
+
for (const p of positions) {
|
|
188
|
+
ranges.push([p, p + 1]);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
for (const kw of kws) {
|
|
194
|
+
const kwRunes = toRunes(kw);
|
|
195
|
+
if (kwRunes.length === 0) {
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
const idx = runeIndex(lowerRunes, kwRunes);
|
|
199
|
+
if (idx >= 0) {
|
|
200
|
+
ranges.push([idx, idx + kwRunes.length]);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return mergeRanges(ranges);
|
|
205
|
+
}
|
|
206
|
+
function hasRunePrefix(s, prefix) {
|
|
207
|
+
if (prefix.length > s.length) {
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
for (let i = 0; i < prefix.length; i++) {
|
|
211
|
+
if (s[i] !== prefix[i]) {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
/** 返回 pat 作为 s 子序列匹配时的各字符 rune 位置;不完整匹配返回 []。 */
|
|
218
|
+
function subsequenceRunePositions(pat, s) {
|
|
219
|
+
if (pat.length === 0) {
|
|
220
|
+
return [];
|
|
221
|
+
}
|
|
222
|
+
const positions = [];
|
|
223
|
+
let pi = 0;
|
|
224
|
+
for (let si = 0; si < s.length && pi < pat.length; si++) {
|
|
225
|
+
if (s[si] === pat[pi]) {
|
|
226
|
+
positions.push(si);
|
|
227
|
+
pi++;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (pi < pat.length) {
|
|
231
|
+
return [];
|
|
232
|
+
}
|
|
233
|
+
return positions;
|
|
234
|
+
}
|
|
235
|
+
/** 匹配模式分派:单候选是否命中全部关键字。 */
|
|
236
|
+
function matchCandidate(s, kws, opts = {}) {
|
|
237
|
+
switch (opts.mode ?? 'substring') {
|
|
238
|
+
case 'token':
|
|
239
|
+
return matchTokenPrefixAll(s, kws, opts.sep ?? '');
|
|
240
|
+
case 'fuzzy':
|
|
241
|
+
return matchFuzzyAll(s, kws);
|
|
242
|
+
default:
|
|
243
|
+
return matchSubstringAll(s, kws);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
/** 归一化 FilterOptions(对齐协议 --mode/--sep 语义)。 */
|
|
247
|
+
function normalizeOpts(opts = {}) {
|
|
248
|
+
return { mode: opts.mode ?? 'substring', sep: opts.sep ?? '' };
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* filterCands — 过滤 + 高亮区间(纯同步,保留输入顺序)。
|
|
252
|
+
* 描述不参与过滤——过滤永远针对"选中什么"而非"展示什么"。空查询返回全部。
|
|
253
|
+
*/
|
|
254
|
+
function filterCands(cands, query, opts = {}) {
|
|
255
|
+
const kws = splitKeywords(query);
|
|
256
|
+
if (kws.length === 0) {
|
|
257
|
+
return cands.map((c) => ({ value: c.value, desc: c.desc ?? '', ranges: [] }));
|
|
258
|
+
}
|
|
259
|
+
const norm = normalizeOpts(opts);
|
|
260
|
+
const out = [];
|
|
261
|
+
for (const c of cands) {
|
|
262
|
+
if (matchCandidate(c.value, kws, norm)) {
|
|
263
|
+
out.push({
|
|
264
|
+
value: c.value,
|
|
265
|
+
desc: c.desc ?? '',
|
|
266
|
+
ranges: highlightRanges(c.value, query, norm),
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return out;
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* autoResolve — 把查询字符串解析为唯一候选(供 --auto 自动选中):
|
|
274
|
+
* 1. 精确匹配(大小写敏感)直接命中;
|
|
275
|
+
* 2. 否则大小写不敏感的唯一前缀匹配;
|
|
276
|
+
* 无匹配或多个前缀匹配时返回 null。
|
|
277
|
+
*/
|
|
278
|
+
function autoResolve(cands, query) {
|
|
279
|
+
if (query === '') {
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
for (const c of cands) {
|
|
283
|
+
if (c.value === query) {
|
|
284
|
+
return c.value;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
const lq = query.toLowerCase();
|
|
288
|
+
let only = null;
|
|
289
|
+
for (const c of cands) {
|
|
290
|
+
if (c.value.toLowerCase().startsWith(lq)) {
|
|
291
|
+
if (only !== null && only !== c.value) {
|
|
292
|
+
return null; // 多个前缀匹配,无法唯一确定
|
|
293
|
+
}
|
|
294
|
+
only = c.value;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return only;
|
|
22
298
|
}
|
|
23
299
|
/**
|
|
24
300
|
* filter — 过滤候选并返回命中项(保留输入顺序)+ 高亮区间。
|
|
25
|
-
*
|
|
26
|
-
* @param cands 候选(字符串或结构化 Candidate)
|
|
27
|
-
* @param query 过滤关键字(空格分词,空串返回全部)
|
|
28
|
-
* @param opts 匹配模式(默认 substring)
|
|
29
|
-
* @returns 命中候选,`ranges` 为命中的 rune 区间(空查询恒为 [])
|
|
301
|
+
* 与协议 filter --json 等价;空查询返回全部(ranges 恒为 [])。
|
|
30
302
|
*/
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
(0, engine_js_1.assertSuccess)(inv, 'picktui filter');
|
|
34
|
-
return (0, engine_js_1.parseJSON)(inv.stdout, 'picktui filter');
|
|
303
|
+
function filter(cands, query = '', opts = {}) {
|
|
304
|
+
return Promise.resolve(filterCands((0, cand_js_1.toStructuredCands)(cands), query, opts));
|
|
35
305
|
}
|
|
36
306
|
/**
|
|
37
307
|
* resolve — 把 query 解析为唯一候选(精确 → 唯一前缀,大小写不敏感前缀)。
|
|
38
|
-
*
|
|
39
|
-
* @returns 解析出的选中值;无匹配/多个前缀匹配返回 null(引擎退出码 1)
|
|
308
|
+
* @returns 解析出的选中值;无匹配/多个前缀匹配/空 query 返回 null
|
|
40
309
|
*/
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
if (inv.exitCode === 1) {
|
|
44
|
-
return null; // 协议:无唯一解 → stdout 空 + 退出码 1
|
|
45
|
-
}
|
|
46
|
-
(0, engine_js_1.assertSuccess)(inv, 'picktui resolve');
|
|
47
|
-
const parsed = (0, engine_js_1.parseJSON)(inv.stdout, 'picktui resolve');
|
|
48
|
-
return parsed.value;
|
|
310
|
+
function resolve(cands, query, _opts = {}) {
|
|
311
|
+
return Promise.resolve(autoResolve((0, cand_js_1.toStructuredCands)(cands), query));
|
|
49
312
|
}
|